Fix Home navigation ownership and merge watch lanes

This commit is contained in:
justinzeus 2026-11-07 15:23:45 +01:00
parent c2f7ccc438
commit 12c201dc55
41 changed files with 671 additions and 307 deletions

View file

@ -110,12 +110,12 @@ class HomeCaptureTest {
@Test fun st019_picksUnavailable() = capture("st-019", mode = "picks-unavailable", row = "trending")
@Test fun st020_missingArt() = capture("st-020", person = "shared", mode = "missing", row = "lib-movies")
@Test fun st096_widestTilePlate() = capture("st-096", person = "mindel", row = "new", column = 4)
@Test fun st100_episodePlate() = capture("st-100", person = "mindel", row = "nu")
@Test fun st100_episodePlate() = capture("st-100", person = "mindel", row = "cw")
@Test fun st099_posterPlate() = capture("st-099", row = "lib-movies", column = 1)
@Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2)
@Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "cw", column = 2)
@Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4)
@Test fun st103_heroRatings() = capture("st-103")
@Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "nu")
@Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "cw")
@Test fun focusHeroDetails() = capture("focus-hero-details", column = 1)
@Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m")
}

View file

@ -0,0 +1,68 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Bright artwork cannot reappear between a landscape title scrim and the card's solid state edge. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HomeGradientTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun focusedAndUnfocusedTileAndWideScrimsReachTheBottomWithoutDarkeningTheUpperImage() {
val bitmap = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.WHITE) }
val art = mapOf("solid" to bitmap.asImageBitmap())
val title = HomeTitle("episode", "Episode", "episode", backdrop = "solid", seriesTitle = "Series")
var row by mutableStateOf(HomeRow("gradient", HomeRowKind.Shelf, cards = listOf(title), cardKind = HomeCardKind.Tile))
var focused by mutableStateOf(false)
val memory = HomeViewMemory()
rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) {
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) {
HomeShelf(row, memory, focused, 0, emptyList(), {}, {})
}
} } }
val directory = kotlin.io.path.createTempDirectory("wolfskin-gradient-").toFile()
try {
for (kind in listOf(HomeCardKind.Tile, HomeCardKind.Wide)) for (selected in listOf(false, true)) {
rule.runOnIdle { row = row.copy(cardKind = kind); focused = selected }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val bounds = rule.onNodeWithTag("home-card:gradient:0").fetchSemanticsNode().boundsInRoot
val file = File(directory, "$kind-$selected.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val rendered = BitmapFactory.decodeFile(file.absolutePath)
try {
val scale = bounds.height / row.cardHeight.value
val x = (bounds.right - WolfskinTokens.Home.cardPadding.value * scale * 2).toInt()
val bottom = (bounds.bottom - WolfskinTokens.CardMetadata.bottom.value * scale - scale).toInt()
val upper = (bounds.top + bounds.height * .4f).toInt()
assertTrue("$kind/$selected image leaks below the scrim", android.graphics.Color.red(rendered.getPixel(x, bottom)) < 80)
assertTrue("$kind/$selected scrim starts too high", android.graphics.Color.red(rendered.getPixel(x, upper)) > 245)
} finally { rendered.recycle() }
}
} finally { directory.deleteRecursively() }
}
}

View file

@ -98,10 +98,10 @@ class HomeKeysTest {
var action: HomeAction? = null
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } }
press("DOWN")
rule.onNodeWithTag("home-card:nu:2").performMouseInput { enter(center) }
rule.onNodeWithTag("home-card:cw:2").performMouseInput { enter(center) }
rule.waitForIdle()
assertEquals("nu", snapshot.rows[shell.view.row].id)
assertEquals(2, shell.view.cards["nu"])
assertEquals("cw", snapshot.rows[shell.view.row].id)
assertEquals(2, shell.view.cards["cw"])
assertNull(action)
}

View file

@ -0,0 +1,175 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import androidx.compose.ui.MotionDurationScale
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
/** Directional navigation follows stable rows through page returns and asynchronous replacement. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class HomeNavigationTest {
private val motionScale = object : MotionDurationScale { override val scaleFactor = 1f }
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = motionScale)
private val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
private val cards = listOf(HomeTitle("episode", "Episode", "episode", seriesTitle = "Series", season = 1, episode = 2))
private val rows = listOf(
HomeRow("hero", HomeRowKind.Hero, cards = cards),
HomeRow("cw", HomeRowKind.Shelf, "Continue watching", cards, HomeCardKind.Wide),
HomeRow("nu", HomeRowKind.Shelf, "Next up", cards, HomeCardKind.Tile),
HomeRow("new", HomeRowKind.Shelf, "New in your library", cards, HomeCardKind.Tile),
HomeRow("pending", HomeRowKind.Shelf, skeleton = true),
HomeRow("section", HomeRowKind.Section),
) + (0..5).map { HomeRow("row-$it", HomeRowKind.Shelf, "Shelf $it", cards) }
private var snapshot by mutableStateOf(HomeSnapshot(rows))
private var title by mutableStateOf<TitleState?>(null)
private var outgoing by mutableStateOf<TitleState?>(null)
private var retainOutgoing = false
@Test fun downScrollAfterBackIsMonotonicAtSixteenMillisecondFrames() {
retainOutgoing = true
mount()
press(Key.DirectionDown)
press(Key.DirectionCenter)
press(Key.Back)
rule.runOnIdle { outgoing = null }
rule.waitForIdle()
rule.mainClock.autoAdvance = false
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
val directory = File(System.getProperty("wolfskin.captureDirectory"), "home-scroll-frames").apply { mkdirs() }
val series = mutableListOf<String>("milliseconds,index,offset")
var previousIndex = shell.view.listState.firstVisibleItemIndex
var previousOffset = shell.view.listState.firstVisibleItemScrollOffset
val start = previousIndex to previousOffset
repeat((WolfskinTokens.Motion.scroll + 64) / 16) { frame ->
rule.mainClock.advanceTimeBy(16)
rule.waitForIdle()
val list = shell.view.listState
val index = list.firstVisibleItemIndex
val offset = list.firstVisibleItemScrollOffset
assertTrue("Scroll must not snap backward at frame $frame", index > previousIndex || index == previousIndex && offset >= previousOffset)
series += "${(frame + 1) * 16},$index,$offset"
rule.onRoot().captureRoboImage(File(directory, "frame-${frame.toString().padStart(2, '0')}.png").absolutePath)
previousIndex = index
previousOffset = offset
}
File(directory, "position.csv").writeText(series.joinToString("\n"))
assertNotEquals("Down must actually scroll after Back", start, previousIndex to previousOffset)
assertEquals(2, shell.view.row)
rule.mainClock.autoAdvance = true
walkDown(start = 2)
}
@Test fun outgoingEpisodeDuringBackCannotTakeOverTheHomeScroller() {
retainOutgoing = true
mount()
press(Key.DirectionDown)
press(Key.DirectionDown)
press(Key.DirectionCenter)
rule.onNodeWithTag("title-page").assertIsDisplayed()
press(Key.Back)
rule.runOnIdle { outgoing = null }
rule.waitForIdle()
assertEquals(2, shell.view.row)
walkDown(start = 2)
}
@Test fun downAdvancesEveryRowAndScrollsAfterEpisodeBack() {
mount()
walkDown()
repeat(rows.count { it.focusable } - 1) { press(Key.DirectionUp) }
press(Key.DirectionDown)
val saved = shell.view
press(Key.DirectionCenter)
rule.onNodeWithTag("title-page").assertIsDisplayed()
press(Key.Back)
assertSame(saved, shell.view)
assertEquals(1, shell.view.row)
walkDown(start = 1)
}
@Test fun refreshReorderInsertionRemovalAndSkeletonReadyKeepDownProgress() {
mount()
press(Key.DirectionDown)
press(Key.DirectionDown)
assertEquals("nu", snapshot.rows[shell.view.row].id)
rule.runOnIdle { snapshot = snapshot.copy(rows = listOf(rows[0], rows[3], rows[1], rows[2]) + rows.drop(4)) }
rule.waitForIdle()
assertEquals("nu", snapshot.rows[shell.view.row].id)
rule.runOnIdle { snapshot = snapshot.copy(rows = snapshot.rows.map { if (it.id == "pending") it.copy(cards = cards, skeleton = false) else it }) }
press(Key.DirectionDown)
assertEquals("pending", snapshot.rows[shell.view.row].id)
rule.runOnIdle { snapshot = snapshot.copy(rows = listOf(HomeRow("insert", HomeRowKind.Shelf, cards = cards)) + snapshot.rows) }
rule.waitForIdle()
assertEquals("pending", snapshot.rows[shell.view.row].id)
rule.runOnIdle { snapshot = snapshot.copy(rows = snapshot.rows.filter { it.id != "pending" }) }
rule.waitForIdle()
assertFalse(shell.navFocused)
walkDown(start = shell.view.row)
}
@Test fun loadingToReadyThenRefreshDoesNotTrapFocusOnPendingRows() {
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true))
mount()
assertTrue(shell.navFocused)
rule.runOnIdle { snapshot = HomeSnapshot(rows) }
rule.waitForIdle()
assertFalse(shell.navFocused)
walkDown()
}
private fun mount() {
rule.setContent { WolfskinTheme {
val page = title
if (page != null) ScreenEntry(shell, "title/episode", true) { TitleSurface(page, shell) }
else ScreenEntry(shell, "home", true) { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action ->
if (action is HomeAction.Details) {
shell.enterDestination("title/episode")
title = TitleState(TitlePageData(TitleItem(action.title.id, action.title.title, "episode", held = true)), shell.person) { effect ->
if (effect == TitleAction.Pop) {
if (retainOutgoing) outgoing = title
shell.enterDestination("home")
title = null
}
}
}
} } }
outgoing?.let { ScreenEntry(shell, "title/episode", false) { TitleSurface(it, shell) } }
} }
rule.waitForIdle()
}
private fun walkDown(start: Int = 0) {
val indices = snapshot.rows.indices.filter { it >= start && snapshot.rows[it].focusable }
for (index in indices) {
if (index != start) press(Key.DirectionDown)
assertEquals("Down must advance to ${snapshot.rows[index].id}", index, shell.view.row)
if (snapshot.rows[index].kind == HomeRowKind.Shelf) {
val bounds = rule.onNodeWithTag("home-card:${snapshot.rows[index].id}:0").fetchSemanticsNode().boundsInRoot
assertTrue("Focused card must clear navigation: $bounds", bounds.top >= WolfskinTokens.Layout.navBand.value * rule.density.density)
assertTrue("Focused card must clear screen bottom: $bounds", bounds.bottom <= WolfskinTokens.Shell.stageHeight.value * rule.density.density)
}
}
press(Key.DirectionDown)
assertEquals(indices.last(), shell.view.row)
}
private fun press(key: Key) {
rule.onRoot().performKeyInput { pressKey(key) }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
}
}

View file

@ -44,7 +44,26 @@ class HomeRowsTest {
@Test fun firstReadSkeletonHasNoFocusableContent() {
val rows = buildHomeRows(HomeLibrary(), continuation, loading = true)
assertEquals(listOf(HomeRowKind.Hero, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf), rows.map { it.kind })
assertTrue(rows.all { !it.focusable })
}
@Test fun mergedWatchRowPrefersResumePerSeriesAndInterleavesByWatchRecency() {
val inProgress = next.copy(id = "resume-a", seriesId = "a", resumeSeconds = 40.0, lastPlayedAt = 30)
val olderResume = inProgress.copy(id = "older-a", lastPlayedAt = 10)
val nextSameSeries = next.copy(id = "next-a", seriesId = "a", lastPlayedAt = 50)
val nextOtherSeries = next.copy(id = "next-b", seriesId = "b", lastPlayedAt = 40)
val film = resume.copy(id = "film", resumeSeconds = 20.0, lastPlayedAt = 20)
val unknown = next.copy(id = "unknown", seriesId = "c")
val library = HomeLibrary(listOf(inProgress, olderResume, film), listOf(nextSameSeries, nextOtherSeries, nextOtherSeries, unknown))
assertEquals(listOf("next-b", "resume-a", "film", "unknown"), continueWatching(library).map { it.id })
val rows = buildHomeRows(library, continuation)
assertFalse(rows.any { it.id == "nu" })
assertEquals(listOf("next-b", "film", "unknown"), rows.single { it.id == "cw" }.cards.map { it.id })
}
@Test fun oldCachedEpisodesWithoutSeriesIdsDeduplicateBySeriesName() {
val checkpoint = next.copy(id = "resume", seriesTitle = "Same series", resumeSeconds = 10.0)
val following = next.copy(id = "next", seriesTitle = "Same series")
assertEquals(listOf(checkpoint), continueWatching(HomeLibrary(listOf(checkpoint), listOf(following))))
}
}

View file

@ -0,0 +1,102 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** A retained outgoing title cannot steal another screen's viewport or Android input on Back. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class ScreenEntryNavigationTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
private val credits = (0..35).map { TitleItem("credit-$it", "Credit $it", "movie") }
private val grid = LibraryGridState("Library", credits.map { LibraryTitle(it.id, it.name, "movie") }).apply { toolbar = false }
private val person = PersonState(TitlePerson("actor", "Actor"), credits, {})
private val title = TitleState(TitlePageData(TitleItem("parent", "Parent", "movie"), cast = listOf(TitlePerson("actor", "Actor")), related = credits.take(10)), shell.person) {}
private val fixture = discoverTestFixture()
private val snapshot = fixture.snapshot("justin")
private val discover = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), {}, {}, fixture::service)
private var childVisible by mutableStateOf(false)
private var outgoingVisible by mutableStateOf(false)
private lateinit var child: TitleState
@Test fun libraryBackRestoresCardAndDownReachesFinalGridRow() = verify("movies")
@Test fun discoverBackRestoresShelfAndDownReachesFinalShelf() = verify("discover")
@Test fun personBackRestoresCreditAndDownReachesFinalGridRow() = verify("person/actor")
@Test fun titleBackRestoresRowAndDownReachesRelatedShelf() = verify("title/parent")
private fun verify(route: String) {
shell.enterDestination(route)
if (route == "discover") discover.focus(discover.firstShelf, 0)
child = TitleState(TitlePageData(TitleItem("child", "Child episode", "episode", held = true)), shell.person) { action ->
if (action == TitleAction.Pop) {
shell.enterDestination(route)
childVisible = false
outgoingVisible = true
}
}
rule.setContent { WolfskinTheme {
if (!childVisible) ScreenEntry(shell, route, true) { host(route) }
if (childVisible || outgoingVisible) ScreenEntry(shell, "title/child", childVisible) { TitleSurface(child, shell) }
} }
rule.waitForIdle()
val view = shell.view
val before = position(route)
rule.runOnIdle { shell.enterDestination("title/child"); childVisible = true }
press(Key.Back)
rule.runOnIdle { outgoingVisible = false }
rule.waitForIdle()
assertSame(view, shell.view)
assertEquals(before, position(route))
repeat(maxOf(credits.size, discover.rowCount)) {
val previous = position(route)
press(Key.DirectionDown)
assertTrue("$route Down cannot move backward: $previous -> ${position(route)}", position(route) >= previous)
}
val expected = when {
route == "movies" -> (grid.items.lastIndex / grid.columns) * grid.columns
route == "discover" -> discover.rowCount - 1
route.startsWith("person/") -> (credits.lastIndex / WolfskinTokens.Behaviour.gridColumns.toInt()) * WolfskinTokens.Behaviour.gridColumns.toInt()
else -> title.rows.lastIndex
}
assertEquals("$route must reach its last row after Back", expected, position(route))
assertTrue("$route must scroll its last row into composition", view.listState.firstVisibleItemIndex > 0)
}
@Composable private fun host(route: String) {
when {
route == "movies" -> ShellSurface(shell) { LibraryScreen(shell, grid, it) }
route == "discover" -> ShellSurface(shell) { DiscoverScreen(discover, it) }
route.startsWith("person/") -> PersonSurface(person, shell)
else -> TitleSurface(title, shell)
}
}
private fun position(route: String): Int = when {
route == "movies" -> grid.index
route == "discover" -> discover.focusedRow
route.startsWith("person/") -> person.index
else -> title.rows.indexOf(title.row)
}
private fun press(key: Key) {
rule.onRoot().performKeyInput { pressKey(key) }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
}
}