Match Discover shadows, text rhythm and asynchronous focus
Some checks failed
review/design-claude Changes requested: 3 blocking design deviations (st-032 sheet rhythm, st-036 button squeeze, hero stack offset)

This commit is contained in:
justinzeus 2026-11-05 19:39:13 +01:00
parent 4e62aad638
commit 1299034991
21 changed files with 293 additions and 80 deletions

View file

@ -0,0 +1,101 @@
// SPDX-License-Identifier: GPL-2.0-only
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.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import kotlinx.coroutines.CompletableDeferred
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.DiscoverFixture
import org.justinvisser.wolfskin.design.DiscoverScreen
import org.justinvisser.wolfskin.design.DiscoverState
import org.justinvisser.wolfskin.design.DiscoverStatus
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.ShellSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** The real asynchronous host must return focus from its loading nav to the first loaded action. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverLoadingFocusTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun firstOkAfterLoadingOpensTheHeroInsteadOfSelectingTheNav() {
val pending = CompletableDeferred<DiscoverFixture>()
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
pickerVisible = false
enterDestination("discover")
}
lateinit var current: DiscoverState
rule.setContent {
val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, loadFixture = { pending.await() })
current = state
WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } }
}
rule.runOnIdle {
assertEquals(DiscoverStatus.Loading, current.snapshot.status)
assertTrue(shell.navFocused)
val json = rule.activity.assets.open("wolfskin-discover/fixture.json").bufferedReader().use { it.readText() }
pending.complete(DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" })
}
rule.waitForIdle()
rule.runOnIdle {
assertEquals(DiscoverStatus.Ready, current.snapshot.status)
assertFalse(shell.navFocused)
assertEquals(0, current.focusedRow)
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
rule.runOnIdle {
assertNotNull(current.title)
assertEquals("The Amazing Spider-Man", current.title!!.title)
assertEquals("discover-title/${current.title!!.key}", shell.route)
}
}
@Test fun finishingLoadPreservesANavTargetChosenWhileLoading() {
val pending = CompletableDeferred<DiscoverFixture>()
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
pickerVisible = false
enterDestination("discover")
}
lateinit var current: DiscoverState
rule.setContent {
val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, loadFixture = { pending.await() })
current = state
WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } }
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight) }
rule.runOnIdle {
assertEquals(DiscoverStatus.Loading, current.snapshot.status)
assertEquals(2, shell.navIndex)
val json = rule.activity.assets.open("wolfskin-discover/fixture.json").bufferedReader().use { it.readText() }
pending.complete(DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" })
}
rule.waitForIdle()
rule.runOnIdle {
assertEquals(DiscoverStatus.Ready, current.snapshot.status)
assertTrue(shell.navFocused)
assertEquals(2, shell.navIndex)
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
rule.runOnIdle {
assertEquals("movies", shell.route)
assertEquals(null, current.title)
}
}
}

View file

@ -29,15 +29,23 @@ internal val LocalDiscoverState = compositionLocalOf<DiscoverState> { error("Dis
/** No client or endpoint is constructed before TV sign-in exists; release stays explicitly signed out. */
@Composable
internal fun rememberDiscoverState(shell: ShellState, push: (String) -> Unit, pop: () -> Unit): DiscoverState {
internal fun rememberDiscoverState(
shell: ShellState,
push: (String) -> Unit,
pop: () -> Unit,
loadFixture: suspend (Context) -> DiscoverFixture = { context -> withContext(Dispatchers.IO) { readDiscoverFixture(context) } },
): DiscoverState {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var state by remember(shell, shell.person.key) { mutableStateOf(DiscoverState(shell, DiscoverSnapshot(status = if (BuildConfig.DEBUG) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn), SignedOutDiscoverOperations, scope, push, pop)) }
LaunchedEffect(shell, shell.person.key) {
if (BuildConfig.DEBUG) {
val fixture = withContext(Dispatchers.IO) { readDiscoverFixture(context) }
val fixture = loadFixture(context)
val snapshot = fixture.snapshot(shell.person.key)
state = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service)
val resumeContent = shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)
val loaded = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service)
state = loaded
if (resumeContent) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1)
}
}
LaunchedEffect(shell.route, state) { state.enterRoute(shell.route) }