Smooth Discover hero handoffs and scope lane focus to each visit

This commit is contained in:
justinzeus 2026-11-07 15:56:19 +01:00
parent c2f7ccc438
commit 12e908389f
14 changed files with 801 additions and 87 deletions

View file

@ -0,0 +1,65 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.Modifier
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.GraphicsMode
import org.robolectric.annotation.Config
/** A catalogue read has no right to move the destination's focus into the root navigation. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverDetailsFocusTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun heroDetailsRetainsDestinationFocusDuringLoadingAndLateMetadata() {
rule.mainClock.autoAdvance = false
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
val snapshot = discoverTestFixture().snapshot("justin")
val discover = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination)
val selected = requireNotNull(discover.hero)
val title = TitleState(TitlePageData(TitleItem(selected.key, selected.title, selected.type), status = TitleReadStatus.Loading), shell.person) {}
val art = android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
rule.setContent { WolfskinTheme {
CompositionLocalProvider(LocalDiscoverArt provides { art }) {
val modifier = Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)
if (shell.route.startsWith("title/")) TitleSurface(title, shell, modifier)
else ShellSurface(shell, modifier) { DiscoverScreen(discover, it) }
}
} }
rule.mainClock.advanceTimeBy(640)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
repeat(6) {
rule.mainClock.advanceTimeBy(16)
rule.waitForIdle()
assertFalse("Loading cannot steal destination focus on a later frame", shell.navFocused)
}
assertEquals("title/${android.net.Uri.encode(selected.key)}", shell.route)
assertFalse("The destination owns focus even while its read is pending", shell.navFocused)
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
assertFalse("Down on a loading title must not enter nav or a skeleton", shell.navFocused)
rule.runOnIdle { title.update(title.data.copy(status = TitleReadStatus.Ready)) }
rule.mainClock.advanceTimeBy(640)
val before = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
rule.runOnIdle { title.update(title.data.copy(item = title.data.item.copy(overview = "Late overview", ratings = listOf(LibraryRating("rt", 88.0))))) }
repeat(24) {
rule.mainClock.advanceTimeBy(16)
assertFalse(shell.navFocused)
assertEquals("Loaded actions cannot replay their entry or move after late metadata", before, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
}
}
}

View file

@ -0,0 +1,240 @@
package org.justinvisser.wolfskin.design
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.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import coil3.intercept.Interceptor
import coil3.request.ErrorResult
import com.github.takahirom.roborazzi.captureRoboImage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import org.json.JSONArray
import org.json.JSONObject
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 org.robolectric.annotation.GraphicsMode
import java.io.File
import java.io.IOException
/** Real Discover artwork, copy and pager are sampled on every manually advanced native frame. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverHeroSequenceTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val samples = JSONArray()
private lateinit var directory: File
private lateinit var state: DiscoverState
private lateinit var shell: ShellState
private val artAvailable = mutableStateOf(true)
private val reduced = mutableStateOf(false)
private var previousPixel: Int? = null
private var largestJump = 0
private var previousLayers = emptyMap<String, Pair<Float, Float>>()
private val previousDotWidths = mutableMapOf<Int, Float>()
private var previousSelection: Int? = null
private fun launch(scenario: String, failedArt: Boolean = false) {
directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-hero/$scenario").apply { mkdirs() }
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
rule.mainClock.autoAdvance = false
WolfskinImages.configureNetwork(rule.activity) { add(Interceptor { chain ->
if (chain.request.data.toString().startsWith("sequence:")) {
if (failedArt) ErrorResult(image = null, request = chain.request, throwable = IOException("Controlled artwork failure")) else awaitCancellation()
} else chain.proceed()
}) }
val fixture = discoverTestFixture()
val colors = listOf(android.graphics.Color.RED, android.graphics.Color.BLUE, android.graphics.Color.GREEN)
val titles = fixture.snapshot("justin").picks.take(3).mapIndexed { index, title ->
title.copy(backdrop = "sequence:$index", poster = null, posterOnly = false, score = 100.0 - index)
}
val art = colors.map { color -> Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(color) }.asImageBitmap() }
val snapshot = fixture.snapshot("justin").copy(picks = titles)
shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination)
rule.setContent { WolfskinTheme {
CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalDiscoverArt provides { path ->
path.removePrefix("sequence:").toIntOrNull()?.let { if (it != 1 || artAvailable.value) art[it] else null }
}) {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) }
}
} }
rule.mainClock.advanceTimeBy(640)
}
@Test fun interruptedHeroNeverSnapsToAnIntermediateSelection() {
launch("interrupted")
sample("settled", 0)
press(Key.DirectionRight)
frames("first", 12)
press(Key.DirectionRight)
frames("interrupted", 28)
press(Key.DirectionLeft)
frames("reverse", 28)
finish()
assertSmoothPixels()
assertEquals(1, state.slide)
}
@Test fun slowArtRetainsBothThePaintedHeroAndItsCopyUntilReady() {
launch("slow")
sample("settled", 0)
val original = previousPixel
rule.runOnIdle { artAvailable.value = false }
press(Key.DirectionRight)
frames("pending", 24)
assertEquals("Pending art must not replace the painted backdrop", original, previousPixel)
assertEquals(state.heroes.first().key, copyIdentities().single())
rule.runOnIdle { artAvailable.value = true }
frames("ready", 28)
finish()
assertSmoothPixels()
assertEquals(state.hero!!.key, copyIdentities().single())
}
@Test fun failedArtBlendsToItsFinalFallbackWithoutErasingThePreviousHero() {
launch("failed", failedArt = true)
sample("settled", 0)
rule.runOnIdle { artAvailable.value = false }
press(Key.DirectionRight)
frames("fallback", 28)
rule.onNodeWithTag("discover-missing-art").assertExists()
finish()
assertSmoothPixels()
assertEquals(state.hero!!.key, copyIdentities().single())
}
@Test fun repeatedPressesRetargetTheCurrentCompositeWithoutSnapBack() {
launch("rapid")
sample("settled", 0)
listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionLeft, Key.DirectionLeft, Key.DirectionRight, Key.DirectionRight).forEachIndexed { index, key ->
press(key)
frames("press-$index", 3)
}
repeat(48) { index ->
press(if (index % 2 == 0) Key.DirectionLeft else Key.DirectionRight)
frames("burst-$index", 1)
}
frames("settled-latest", 28)
finish()
assertSmoothPixels()
assertEquals(state.hero!!.key, copyIdentities().single())
}
@Test fun reducedMotionKeepsTheBlendButRemovesCopyTranslation() {
launch("reduced")
rule.runOnIdle { reduced.value = true }
press(Key.DirectionRight)
frames("reduced", (WolfskinTokens.Motion.heroFade + 15) / 16)
finish()
assertEquals(state.hero!!.key, copyIdentities().single())
assertEquals(1, rule.onAllNodesWithTag("hero-incoming", useUnmergedTree = true).fetchSemanticsNodes().size)
assertTrue(rule.onAllNodesWithTag("hero-outgoing", useUnmergedTree = true).fetchSemanticsNodes().isEmpty())
val moving = (0 until samples.length()).map(samples::getJSONObject).filter { row ->
val layers = row.getJSONArray("layers")
(0 until layers.length()).map(layers::getJSONObject).any {
it.getString("kind") == "hero-outgoing" && it.getDouble("alpha") > 0.0 && it.getDouble("alpha") < 1.0
}
}.map { it.getLong("timeMs") }
assertTrue("Reduced artwork fade cannot retain the ordinary 320ms lifetime", moving.last() - moving.first() <= WolfskinTokens.Motion.reducedFade)
}
private fun copyIdentities() = rule.onAllNodesWithTag("hero-copy", useUnmergedTree = true).fetchSemanticsNodes().map { it.config[HeroLayerIdentity] }
private fun press(key: Key) = rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(key); keyUp(key) }
private fun frames(phase: String, count: Int) = repeat(count) { index -> rule.mainClock.advanceTimeBy(16); sample(phase, index) }
private fun finish() = File(directory, "series.json").writeText(samples.toString(2))
private fun assertSmoothPixels() = assertTrue("A 16ms hero frame cannot snap to a full intermediate selection; largest channel jump=$largestJump", largestJump <= 48)
private fun sample(phase: String, index: Int) {
rule.waitForIdle()
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
val pixel = try { bitmap.getPixel(1600, 200) } finally { bitmap.recycle() }
val channels = channels(pixel)
previousPixel?.let { old -> largestJump = maxOf(largestJump, channels.zip(channels(old)).maxOf { (a, b) -> kotlin.math.abs(a - b) }) }
previousPixel = pixel
val copy = JSONArray()
state.heroes.forEach { title -> rule.onAllNodesWithText(title.title, useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
copy.put(JSONObject().put("title", title.key).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top))
} }
val layers = layerSamples()
val dots = JSONArray()
state.heroes.indices.forEach { dot -> rule.onAllNodesWithTag("hero-dot-$dot", useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
previousDotWidths[dot]?.let { previous ->
val range = with(rule.density) { (WolfskinTokens.Discover.dotActive - WolfskinTokens.Discover.dotSize).toPx() }
if (!reduced.value) assertTrue("Pager retarget must not snap a full dot-width step", kotlin.math.abs(node.boundsInRoot.width - previous) <= range / 2)
if (reduced.value) {
val rest = with(rule.density) { WolfskinTokens.Discover.dotSize.toPx() }
assertTrue("Reduced motion snaps pager geometry instead of moving it", node.boundsInRoot.width == rest || node.boundsInRoot.width == rest + range)
}
if (previousSelection == state.slide) {
if (dot == state.slide) assertTrue("The selected pager dot cannot shrink backwards", node.boundsInRoot.width >= previous - .01f)
else assertTrue("An outgoing pager dot cannot grow backwards", node.boundsInRoot.width <= previous + .01f)
}
}
previousDotWidths[dot] = node.boundsInRoot.width
dots.put(JSONObject().put("index", dot).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top).put("width", node.boundsInRoot.width))
} }
previousSelection = state.slide
samples.put(JSONObject().put("phase", phase).put("frame", index).put("timeMs", rule.mainClock.currentTime)
.put("selection", state.slide).put("rgb", JSONArray(channels)).put("copy", copy).put("layers", layers).put("dots", dots).put("navFocused", shell.navFocused))
}
private fun layerSamples(): JSONArray {
val result = JSONArray()
val current = mutableMapOf<String, Pair<Float, Float>>()
val nodes = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes()
val unique = nodes.groupBy { it.config[SemanticsProperties.TestTag] + ":" + it.id }
assertTrue("The artwork composite always has an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f })
val copyResidual = nodes.filter { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
.fold(1f) { residual, node -> residual * (1f - node.config[HeroLayerAlpha]) }
assertTrue("Copy composite cannot fade through a blank frame", 1f - copyResidual >= .5f)
val artCount = nodes.count { !it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
val copyCount = nodes.size - artCount
assertTrue("Sustained presses cannot extend outgoing art lifetimes", artCount <= (WolfskinTokens.Motion.heroFade + 15) / 16 + 3)
assertTrue("Sustained presses cannot accumulate outgoing copy layers", copyCount <= (WolfskinTokens.Motion.heroCopyIn + 15) / 16 + 3)
unique.forEach { (key, matching) -> matching.forEach { node ->
val alpha = node.config[HeroLayerAlpha]
val translation = node.config[HeroLayerTranslation]
val tag = node.config[SemanticsProperties.TestTag]
if (matching.size == 1) {
previousLayers[key]?.let { (previousAlpha, previousTranslation) ->
if (tag == "hero-outgoing" || tag == "hero-copy-outgoing") assertTrue("Outgoing layers cannot restart at full opacity: $key", alpha <= previousAlpha + .001f)
if (tag == "hero-copy-outgoing") assertEquals("Outgoing copy must fade where its entry stopped", previousTranslation, translation, .001f)
if (tag == "hero-copy") {
assertTrue("Copy alpha cannot snap backwards: $key", alpha >= previousAlpha - .001f)
assertTrue("Copy translation cannot snap backwards: $key", translation <= previousTranslation + .001f)
}
}
current[key] = alpha to translation
}
if (reduced.value) assertEquals(0f, translation, .001f)
result.put(JSONObject().put("kind", tag).put("identity", node.config[HeroLayerIdentity]).put("layerId", node.id).put("alpha", alpha)
.put("translationY", translation).put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top))
} }
previousLayers = current
return result
}
private fun channels(pixel: Int) = listOf(android.graphics.Color.red(pixel), android.graphics.Color.green(pixel), android.graphics.Color.blue(pixel))
}

View file

@ -2,6 +2,9 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.Bitmap
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.asImageBitmap
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
@ -19,10 +22,12 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Acceptance data drives real Android events through the shell's production focus adapter. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverKeysTest {
@get:Rule val rule = createComposeRule()
private val fixture = discoverTestFixture()
@ -33,11 +38,14 @@ class DiscoverKeysTest {
{ shell.enterDestination("discover") }, fixture::service)
private fun launch() {
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
rule.setContent {
WolfskinTheme {
CompositionLocalProvider(LocalDiscoverArt provides { art }) {
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
}
}
}
}
}
@ -256,7 +264,69 @@ class DiscoverKeysTest {
assertNull(state.collection)
assertSame(origin, shell.view)
assertEquals(3, shell.view.row)
assertEquals(2, shell.view.cards[state.rowKey])
assertEquals(2, state.column)
}
@Test fun firstEntryStartsLeftmostAndOnlyMovedLanesRememberTheirPosition() {
replay("Open Discover")
press(Key.DirectionDown, Key.DirectionDown)
assertEquals(0, state.column)
repeat(7) { press(Key.DirectionRight) }
val moved = state.column
assertTrue(moved > 0)
press(Key.DirectionDown)
assertEquals("An untouched lane starts at its own left edge, not the previous lane's column", 0, state.column)
press(Key.DirectionUp)
assertEquals(moved, state.column)
}
@Test fun leavingTheTabResetsLaneMovementButPushedTitlesDoNot() {
replay("Open Discover")
press(Key.DirectionDown, Key.DirectionDown)
press(Key.DirectionDown)
repeat(4) { press(Key.DirectionRight) }
val row = state.focusedRow
val moved = state.column
press(Key.DirectionCenter)
assertNotNull("This round trip must open a title, not a service lane", state.title)
press(Key.Back)
assertEquals(moved, state.column)
press(Key.Back)
press(Key.DirectionLeft, Key.DirectionCenter)
assertEquals("home", shell.route)
press(Key.DirectionUp, Key.DirectionRight)
press(Key.DirectionCenter)
assertEquals("discover", shell.route)
assertEquals(row, state.focusedRow)
assertEquals(0, state.column)
assertEquals(0, state.laneFirsts[state.rowKey] ?: 0)
}
@Test fun rebuildingDiscoverDoesNotReuseThePreviousInstancesLaneColumns() {
shell.pickerVisible = false
shell.enterDestination("discover")
state.focus(state.firstShelf, 4)
val rebuilt = DiscoverState(shell, snapshot, operations, CoroutineScope(Dispatchers.Main.immediate))
assertEquals(0, rebuilt.column)
}
@Test fun serviceLanesKeepIndependentPositionsAndResetWithTheRootVisit() {
shell.pickerVisible = false
shell.enterDestination("discover")
state.focus(state.firstShelf + 1, 3)
state.openService(snapshot.services[0])
state.focus(state.firstShelf + 1, 4)
state.key(ShellKey.Back)
assertEquals("A service must not overwrite the root lane", 3, state.column)
state.openService(snapshot.services[1])
state.key(ShellKey.Down)
assertEquals("Different services can reuse lane ids without sharing columns", 0, state.column)
state.key(ShellKey.Back)
state.openService(snapshot.services[0])
state.key(ShellKey.Down)
assertEquals(4, state.column)
state.enterRoute("home")
assertEquals("Leaving Discover clears service positions too", 0, state.column)
}
@Test

View file

@ -0,0 +1,91 @@
package org.justinvisser.wolfskin.design
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.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import coil3.asImage
import coil3.decode.DataSource
import coil3.intercept.Interceptor
import coil3.request.SuccessResult
import com.github.takahirom.roborazzi.captureRoboImage
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.json.JSONArray
import org.json.JSONObject
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 org.robolectric.annotation.GraphicsMode
import java.io.File
/** Network-success artwork must not add Coil's independent alpha ramp beneath the hero blend. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverNetworkHeroTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun delayedNetworkSuccessCannotDipThePaintedHeroOpacity() {
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
rule.mainClock.autoAdvance = false
val red = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.RED) }.asImageBitmap()
val blue = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.BLUE) }.asImage()
val release = CompletableDeferred<Unit>()
WolfskinImages.configureNetwork(rule.activity) { add(Interceptor { chain ->
if (chain.request.data == "network:blue") {
release.await()
SuccessResult(blue, chain.request, DataSource.NETWORK)
} else chain.proceed()
}) }
val fixture = discoverTestFixture()
val titles = fixture.snapshot("justin").picks.take(2).mapIndexed { index, title ->
title.copy(backdrop = if (index == 0) "network:red" else "network:blue", poster = null, posterOnly = false, score = 100.0 - index)
}
val snapshot = fixture.snapshot("justin").copy(picks = titles)
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate))
rule.setContent { WolfskinTheme {
CompositionLocalProvider(LocalDiscoverArt provides { path -> if (path == "network:red") red else null }) {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) }
}
} }
rule.mainClock.advanceTimeBy(640)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(Key.DirectionRight); keyUp(Key.DirectionRight) }
rule.mainClock.advanceTimeBy(192)
rule.onNodeWithText(titles.first().title).assertIsDisplayed()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-network-hero").apply { mkdirs() }
val samples = JSONArray()
rule.runOnIdle { release.complete(Unit) }
var lastPixel = 0
repeat(40) { index ->
rule.mainClock.advanceTimeBy(16)
val file = File(directory, "network-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
lastPixel = try { bitmap.getPixel(1600, 200) } finally { bitmap.recycle() }
val redChannel = android.graphics.Color.red(lastPixel)
val blueChannel = android.graphics.Color.blue(lastPixel)
samples.put(JSONObject().put("timeMs", rule.mainClock.currentTime).put("red", redChannel).put("blue", blueChannel))
assertTrue("A second network-image fade exposes the dark placeholder beneath both heroes at frame $index: red=$redChannel blue=$blueChannel", redChannel + blueChannel >= 250)
}
File(directory, "series.json").writeText(samples.toString(2))
assertEquals(android.graphics.Color.BLUE, lastPixel)
rule.onNodeWithText(titles.last().title).assertIsDisplayed()
}
}

View file

@ -13,6 +13,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithTag
@ -82,6 +85,35 @@ class HeroMotionTest {
} finally { directory.deleteRecursively() }
}
@Test fun homeHeroRetainsTheOutgoingCopyAnchorWhenTheNextTitleWraps() {
rule.mainClock.autoAdvance = false
val first = HomeTitle("first", "Short title", "movie", backdrop = "red")
val next = HomeTitle("next", "A much longer title that must wrap onto a second line in this hero", "movie", backdrop = "blue")
val selected = mutableStateOf(first)
val art = mapOf("red" to Color.Red, "blue" to Color.Blue).mapValues { (_, color) ->
android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888).apply { eraseColor(color.toArgb()) }.asImageBitmap()
}
rule.setContent { WolfskinTheme {
CompositionLocalProvider(LocalTitleBitmaps provides art) {
Box(Modifier.requiredSize(960.dp, 540.dp)) {
HomeHero(HomeRow("hero", HomeRowKind.Hero, cards = listOf(selected.value)), true, 0, emptyList(), {}, {})
}
}
} }
rule.mainClock.advanceTimeBy(640)
val anchor = rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot
val actionAnchor = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot
update { selected.value = next }
repeat(8) {
rule.mainClock.advanceTimeBy(16)
assertEquals("A taller incoming copy cannot move the outgoing title", anchor, rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot)
assertEquals("Hero actions remain stationary outside the copy handoff", actionAnchor, rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot)
}
rule.mainClock.advanceTimeBy(320)
rule.onNodeWithText(first.title).assertDoesNotExist()
rule.onNodeWithText(next.title).assertIsDisplayed()
}
private fun update(block: () -> Unit) {
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()