prep merge discover
This commit is contained in:
commit
abebd52850
20 changed files with 1023 additions and 102 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
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.runtime.saveable.rememberSaveableStateHolder
|
||||
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 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, 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")
|
||||
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 {
|
||||
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)) { modifier ->
|
||||
if (mounted.value) saved.SaveableStateProvider("discover") { DiscoverScreen(state, modifier) }
|
||||
}
|
||||
}
|
||||
} }
|
||||
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() {
|
||||
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
|
||||
val originalLower = lowerPixel
|
||||
rule.runOnIdle { artAvailable.value = false }
|
||||
press(Key.DirectionRight)
|
||||
repeat(24) { frame ->
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
sample("pending", frame)
|
||||
assertEquals("Pending art cannot expose a placeholder beneath the hero", originalLower, lowerPixel)
|
||||
}
|
||||
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 {
|
||||
lowerPixel = bitmap.getPixel(1600, 800)
|
||||
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("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 {
|
||||
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 }
|
||||
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") }
|
||||
assertTrue("Sustained presses cannot extend outgoing art lifetimes", artCount <= (WolfskinTokens.Motion.heroFade + 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" && 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)
|
||||
if (alpha > 0f && previousAlpha > 0f) assertTrue("Visible copy translation cannot snap backwards", 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))
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -59,16 +62,22 @@ class HeroMotionTest {
|
|||
val probe = BitmapFactory.decodeFile(probeFile.absolutePath)
|
||||
try {
|
||||
for (y in listOf(380, 400, 420, 600, 800)) {
|
||||
assertEquals("The outgoing hero must stop at its 400px boundary: y=$y",
|
||||
(if (y < 400) Color.Red else Color.Blue).toArgb(), probe.getPixel(100, y))
|
||||
assertEquals("Pending artwork cannot replace any part of the painted backdrop: y=$y",
|
||||
Color.Red.toArgb(), probe.getPixel(100, y))
|
||||
}
|
||||
assertEquals(400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f)
|
||||
} finally { probe.recycle() }
|
||||
update { ready.value = true }
|
||||
rule.mainClock.advanceTimeBy(176)
|
||||
val mixed = pixel(directory, 100)
|
||||
assertNotEquals(Color.Red.toArgb(), mixed)
|
||||
assertNotEquals(Color.Blue.toArgb(), mixed)
|
||||
val blendedProbe = BitmapFactory.decodeFile(probeFile.absolutePath)
|
||||
try {
|
||||
assertEquals("Only the outgoing blend is clipped at its 400px boundary",
|
||||
400f, rule.onNodeWithTag("hero-outgoing").fetchSemanticsNode().boundsInRoot.height, .01f)
|
||||
assertEquals(Color.Blue.toArgb(), blendedProbe.getPixel(100, 400))
|
||||
assertEquals(Color.Blue.toArgb(), blendedProbe.getPixel(100, 800))
|
||||
} finally { blendedProbe.recycle() }
|
||||
rule.mainClock.advanceTimeBy(400)
|
||||
assertEquals(Color.Blue.toArgb(), pixel(directory, 100))
|
||||
update { selected.value = "green"; ready.value = false }
|
||||
|
|
@ -82,6 +91,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(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)
|
||||
}
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
|
|
@ -66,7 +67,13 @@ class MotionSurfaceTest {
|
|||
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
|
||||
rule.mainClock.advanceTimeBy(2100)
|
||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||
rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible) }
|
||||
rule.onNodeWithTag("title-page").assertIsFocused()
|
||||
rule.runOnIdle {
|
||||
assertFalse(shell.navFocused)
|
||||
assertFalse(shell.pickerVisible)
|
||||
assertEquals("header", state.row)
|
||||
assertEquals(0, state.column)
|
||||
}
|
||||
rule.onRoot().performKeyInput { pressKey(Key.Back) }
|
||||
rule.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue