Restore sequential hero copy and first-appearance entry
This commit is contained in:
parent
7bfa431350
commit
79f64f6e18
5 changed files with 226 additions and 53 deletions
|
|
@ -9,6 +9,7 @@ import androidx.activity.ComponentActivity
|
|||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.input.key.Key
|
||||
|
|
@ -46,13 +47,14 @@ class DiscoverHeroSequenceTest {
|
|||
private val artAvailable = mutableStateOf(true)
|
||||
private val reduced = mutableStateOf(false)
|
||||
private var previousPixel: Int? = null
|
||||
private val mounted = mutableStateOf(true)
|
||||
private var lowerPixel = 0
|
||||
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) {
|
||||
private fun launch(scenario: String, failedArt: Boolean = false, settle: Boolean = true) {
|
||||
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")
|
||||
|
|
@ -73,13 +75,37 @@ class DiscoverHeroSequenceTest {
|
|||
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 {
|
||||
val saved = rememberSaveableStateHolder()
|
||||
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) }
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||
if (mounted.value) saved.SaveableStateProvider("discover") { DiscoverScreen(state, modifier) }
|
||||
}
|
||||
}
|
||||
} }
|
||||
rule.mainClock.advanceTimeBy(640)
|
||||
if (settle) rule.mainClock.advanceTimeBy(640) else rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
|
||||
@Test fun initialCopyEntersOnceAndDoesNotReplayOnDestinationRemount() {
|
||||
launch("initial", settle = false)
|
||||
sample("initial", 0)
|
||||
fun copy() = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true)
|
||||
.fetchSemanticsNodes().single { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
|
||||
assertEquals(0f, copy().config[HeroLayerAlpha], .001f)
|
||||
assertEquals(with(rule.density) { WolfskinTokens.Motion.copyEntryRise.toPx() }, copy().config[HeroLayerTranslation], .001f)
|
||||
frames("entry", 16)
|
||||
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||
rule.runOnIdle { mounted.value = false }
|
||||
frames("absent", 1)
|
||||
rule.runOnIdle { mounted.value = true }
|
||||
repeat(8) { frame ->
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
sample("remount", frame)
|
||||
assertEquals("Returning to a saved destination cannot replay its entrance", 1f, copy().config[HeroLayerAlpha], .001f)
|
||||
assertEquals(0f, copy().config[HeroLayerTranslation], .001f)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
@Test fun interruptedHeroNeverSnapsToAnIntermediateSelection() {
|
||||
|
|
@ -206,7 +232,7 @@ class DiscoverHeroSequenceTest {
|
|||
} }
|
||||
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("lowerRgb", JSONArray(channels(lowerPixel))).put("copy", copy).put("layers", layers).put("dots", dots).put("navFocused", shell.navFocused))
|
||||
.put("selection", state.slide).put("mounted", mounted.value).put("rgb", JSONArray(channels)).put("lowerRgb", JSONArray(channels(lowerPixel))).put("copy", copy).put("layers", layers).put("dots", dots).put("navFocused", shell.navFocused))
|
||||
}
|
||||
|
||||
private fun layerSamples(): JSONArray {
|
||||
|
|
@ -214,14 +240,11 @@ class DiscoverHeroSequenceTest {
|
|||
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)
|
||||
if (mounted.value) assertTrue("The artwork composite always has an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f })
|
||||
val visibleCopy = nodes.count { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") && it.config[HeroLayerAlpha] > 0f }
|
||||
assertTrue("Sequential copy cannot paint overlapping titles, including interruptions", visibleCopy <= 1)
|
||||
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]
|
||||
|
|
@ -229,10 +252,10 @@ class DiscoverHeroSequenceTest {
|
|||
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-outgoing" && alpha > 0f) assertEquals("Visible 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)
|
||||
if (alpha > 0f && previousAlpha > 0f) assertTrue("Visible copy translation cannot snap backwards", translation <= previousTranslation + .001f)
|
||||
}
|
||||
}
|
||||
current[key] = alpha to translation
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ class HeroMotionTest {
|
|||
val anchor = rule.onNodeWithText(first.title).fetchSemanticsNode().boundsInRoot
|
||||
val actionAnchor = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot
|
||||
update { selected.value = next }
|
||||
repeat(8) {
|
||||
repeat(WolfskinTokens.Motion.heroCopyOut / 16) {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
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.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
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
|
||||
|
||||
/** Real Home hero entry, interrupted fade-through and saved remount sampled at native 16 ms steps. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class HomeHeroSequenceTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val first = HomeTitle("first", "Short title", "movie", backdrop = "red")
|
||||
private val next = HomeTitle("next", "A much longer title that must wrap onto a second line in this hero", "movie", backdrop = "blue")
|
||||
private val third = HomeTitle("third", "The final selected title", "movie", backdrop = "green")
|
||||
private val selected = mutableStateOf(first)
|
||||
private val reduced = mutableStateOf(false)
|
||||
private val mounted = mutableStateOf(true)
|
||||
private val samples = JSONArray()
|
||||
private lateinit var directory: File
|
||||
private lateinit var actionBounds: Rect
|
||||
|
||||
@Test fun initialSequentialBurstAndRemountKeepHomeCopyReadable() {
|
||||
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
|
||||
directory = File(System.getProperty("wolfskin.captureDirectory"), "home-hero-copy").apply { mkdirs() }
|
||||
val art = mapOf("red" to android.graphics.Color.RED, "blue" to android.graphics.Color.BLUE, "green" to android.graphics.Color.GREEN).mapValues { (_, color) ->
|
||||
Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(color) }.asImageBitmap()
|
||||
}
|
||||
rule.setContent { WolfskinTheme {
|
||||
val saved = rememberSaveableStateHolder()
|
||||
CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalTitleBitmaps provides art) {
|
||||
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) {
|
||||
if (mounted.value) saved.SaveableStateProvider("home-hero") {
|
||||
HomeHero(HomeRow("hero", HomeRowKind.Hero, cards = listOf(selected.value)), true, 0, emptyList(), {}, {})
|
||||
}
|
||||
}
|
||||
}
|
||||
} }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
actionBounds = rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot
|
||||
sample("initial", 0)
|
||||
assertEquals(0f, copy().config[HeroLayerAlpha], .001f)
|
||||
assertEquals(with(rule.density) { WolfskinTokens.Motion.copyEntryRise.toPx() }, copy().config[HeroLayerTranslation], .001f)
|
||||
frames("entry", 16)
|
||||
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||
rule.runOnIdle { selected.value = next }
|
||||
frames("handoff", 26)
|
||||
assertEquals(next.id, copy().config[HeroLayerIdentity])
|
||||
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||
repeat(24) { index ->
|
||||
rule.runOnIdle { selected.value = if (index % 2 == 0) first else third }
|
||||
frames("burst-$index", 1)
|
||||
}
|
||||
frames("settled", 28)
|
||||
assertEquals(third.id, copy().config[HeroLayerIdentity])
|
||||
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||
rule.runOnIdle { reduced.value = true; selected.value = next }
|
||||
frames("reduced", 20)
|
||||
assertEquals(next.id, copy().config[HeroLayerIdentity])
|
||||
assertEquals(1f, copy().config[HeroLayerAlpha], .001f)
|
||||
rule.runOnIdle { mounted.value = false }
|
||||
frames("absent", 1)
|
||||
rule.runOnIdle { mounted.value = true }
|
||||
repeat(8) { frame ->
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
sample("remount", frame)
|
||||
assertEquals("Returning Home cannot replay the initial entrance", 1f, copy().config[HeroLayerAlpha], .001f)
|
||||
assertEquals(0f, copy().config[HeroLayerTranslation], .001f)
|
||||
}
|
||||
File(directory, "series.json").writeText(samples.toString(2))
|
||||
}
|
||||
|
||||
private fun frames(phase: String, count: Int) = repeat(count) { frame -> rule.mainClock.advanceTimeBy(16); sample(phase, frame) }
|
||||
private fun copy() = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes()
|
||||
.single { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
|
||||
|
||||
private fun sample(phase: String, frame: Int) {
|
||||
rule.waitForIdle()
|
||||
val file = File(directory, "$phase-${frame.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 nodes = rule.onAllNodes(SemanticsMatcher.keyIsDefined(HeroLayerAlpha), useUnmergedTree = true).fetchSemanticsNodes()
|
||||
val copyNodes = nodes.filter { it.config[SemanticsProperties.TestTag].startsWith("hero-copy") }
|
||||
assertTrue("Home must never overlap visible titles, even during bursts", copyNodes.count { it.config[HeroLayerAlpha] > 0f } <= 1)
|
||||
if (mounted.value) {
|
||||
assertTrue("Home artwork retains an opaque base", nodes.any { it.config[SemanticsProperties.TestTag] == "hero-incoming" && it.config[HeroLayerAlpha] == 1f })
|
||||
assertEquals("Copy motion cannot move Home's controls", actionBounds, rule.onNodeWithText("Play").fetchSemanticsNode().boundsInRoot)
|
||||
}
|
||||
val layers = JSONArray()
|
||||
nodes.forEach { node ->
|
||||
val translation = node.config[HeroLayerTranslation]
|
||||
if (reduced.value) assertEquals(0f, translation, .001f)
|
||||
layers.put(JSONObject().put("kind", node.config[SemanticsProperties.TestTag]).put("identity", node.config[HeroLayerIdentity])
|
||||
.put("layerId", node.id).put("alpha", node.config[HeroLayerAlpha]).put("translationY", translation)
|
||||
.put("x", node.boundsInRoot.left).put("y", node.boundsInRoot.top))
|
||||
}
|
||||
samples.put(JSONObject().put("phase", phase).put("frame", frame).put("timeMs", rule.mainClock.currentTime)
|
||||
.put("mounted", mounted.value).put("selection", selected.value.id).put("reduced", reduced.value)
|
||||
.put("rgb", JSONArray(listOf(android.graphics.Color.red(pixel), android.graphics.Color.green(pixel), android.graphics.Color.blue(pixel))))
|
||||
.put("actionX", actionBounds.left).put("actionY", actionBounds.top).put("layers", layers))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue