Implement approved TV loading and bounded motion
All checks were successful
review/code-claude Code review passed on 16684454, no blocking finding
review/design-claude Design review pass: motion numbers equal direction A; 6 non-blocking notes
All checks were successful
review/code-claude Code review passed on 16684454, no blocking finding
review/design-claude Design review pass: motion numbers equal direction A; 6 non-blocking notes
This commit is contained in:
parent
fd48426fa7
commit
1668445418
86 changed files with 2026 additions and 121 deletions
|
|
@ -0,0 +1,97 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
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.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
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
|
||||
|
||||
/** A slow or interrupted artwork read cannot replace the last painted hero with a blank. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class HeroMotionTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun heroWaitsForArtBlendsOnlyItsBoundedRegionAndKeepsTheLatestSelection() {
|
||||
rule.mainClock.autoAdvance = false
|
||||
val selected = mutableStateOf("red")
|
||||
val ready = mutableStateOf(true)
|
||||
val reduced = mutableStateOf(false)
|
||||
val colors = mapOf("red" to Color.Red, "blue" to Color.Blue, "green" to Color.Green)
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(960.dp, 540.dp)) {
|
||||
HeroArtCrossfade(selected.value, selected.value, 200.dp) { title ->
|
||||
val reportReady = LocalHeroArtReady.current
|
||||
if (ready.value) SideEffect { reportReady?.invoke() }
|
||||
Box(Modifier.fillMaxSize().background(colors.getValue(title)))
|
||||
}
|
||||
}
|
||||
} } }
|
||||
val directory = kotlin.io.path.createTempDirectory("wolfskin-hero-").toFile()
|
||||
try {
|
||||
rule.mainClock.advanceTimeBy(32)
|
||||
update { selected.value = "blue"; ready.value = false }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
assertEquals(Color.Red.toArgb(), pixel(directory, 100))
|
||||
val probeFile = File(directory, "hero.png")
|
||||
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(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)
|
||||
rule.mainClock.advanceTimeBy(400)
|
||||
assertEquals(Color.Blue.toArgb(), pixel(directory, 100))
|
||||
update { selected.value = "green"; ready.value = false }
|
||||
rule.mainClock.advanceTimeBy(32)
|
||||
update { selected.value = "red"; ready.value = true }
|
||||
rule.mainClock.advanceTimeBy(400)
|
||||
assertEquals(Color.Red.toArgb(), pixel(directory, 100))
|
||||
update { reduced.value = true; selected.value = "green" }
|
||||
rule.mainClock.advanceTimeBy(128)
|
||||
assertEquals(Color.Green.toArgb(), pixel(directory, 100))
|
||||
} finally { directory.deleteRecursively() }
|
||||
}
|
||||
|
||||
private fun update(block: () -> Unit) {
|
||||
rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
|
||||
private fun pixel(directory: File, y: Int): Int {
|
||||
rule.waitForIdle()
|
||||
val file = File(directory, "hero.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
return try { bitmap.getPixel(100, y) } finally { bitmap.recycle() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.StrictMode
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.ui.MotionDurationScale
|
||||
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.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
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
|
||||
|
||||
/** Delayed metadata and loading animations cannot move content or capture the remote's focus. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class MotionSurfaceTest {
|
||||
private val animatorScale = object : MotionDurationScale { override var scaleFactor by mutableFloatStateOf(1f) }
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
|
||||
private val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||
|
||||
@Test fun lateSeriesReadinessAndRatingsLeaveActionsOverviewAndEpisodesAtTheSameCoordinates() {
|
||||
val shell = shell()
|
||||
val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview")
|
||||
val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview")
|
||||
val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {}
|
||||
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
|
||||
rule.waitForIdle()
|
||||
val before = bounds()
|
||||
for (stage in listOf("Ready", "Downloading", null)) {
|
||||
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Mindel", item = series.copy(ratings = listOf(LibraryRating("rt", 88.0))))) }
|
||||
rule.mainClock.advanceTimeBy(3400)
|
||||
rule.waitForIdle()
|
||||
assertEquals("Late metadata cannot change reserved header geometry", before, bounds())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() {
|
||||
val shell = shell()
|
||||
val actions = mutableListOf<TitleAction>()
|
||||
val state = TitleState(TitlePageData(TitleItem("loading", "", "movie"), status = TitleReadStatus.Loading), viewer, actions::add)
|
||||
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.onRoot().performKeyInput { pressKey(Key.Back) }
|
||||
rule.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) }
|
||||
}
|
||||
|
||||
@Test fun pendingCastAndEpisodesAreSkippedUntilRealCardsExist() {
|
||||
val shell = shell()
|
||||
val series = TitleItem("series", "Series", "tv", held = true, seasons = 1)
|
||||
val related = TitleItem("related", "Related", "movie")
|
||||
val state = TitleState(TitlePageData(series, castStatus = TitleReadStatus.Loading, episodesStatus = TitleReadStatus.Loading, related = listOf(related)), viewer) {}
|
||||
assertEquals(listOf("header", "seasons", "related"), state.rows)
|
||||
state.focus("seasons", 0)
|
||||
state.key(ShellKey.Down, true, 0, shell)
|
||||
assertEquals("related", state.row)
|
||||
state.update(state.data.copy(castStatus = TitleReadStatus.Ready, cast = listOf(TitlePerson("person", "Actor")), episodesStatus = TitleReadStatus.Ready, episodes = listOf(TitleItem("episode", "Pilot", "episode"))))
|
||||
assertEquals("related", state.row)
|
||||
state.key(ShellKey.Up, true, 0, shell)
|
||||
assertEquals("cast", state.row)
|
||||
}
|
||||
|
||||
@Test fun reducedMotionFreezesPulseWhileNormalMotionBreathesWithoutRelayout() {
|
||||
rule.mainClock.autoAdvance = false
|
||||
val reduced = mutableStateOf(true)
|
||||
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||
Box(Modifier.size(200.dp, 140.dp).skeletonPulse().background(WolfskinTokens.Color.surface2).testTag("shape"))
|
||||
}
|
||||
} } }
|
||||
rule.mainClock.advanceTimeBy(32)
|
||||
val directory = kotlin.io.path.createTempDirectory("wolfskin-motion-").toFile()
|
||||
try {
|
||||
val first = pixel(File(directory, "first.png"))
|
||||
val bounds = rule.onNodeWithTag("shape").fetchSemanticsNode().boundsInRoot
|
||||
rule.mainClock.advanceTimeBy(700)
|
||||
assertEquals(first, pixel(File(directory, "reduced.png")))
|
||||
rule.runOnIdle { reduced.value = false; animatorScale.scaleFactor = 0f; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeBy(32)
|
||||
val systemReduced = pixel(File(directory, "system-reduced.png"))
|
||||
rule.mainClock.advanceTimeBy(700)
|
||||
assertEquals(systemReduced, pixel(File(directory, "system-later.png")))
|
||||
rule.runOnIdle { animatorScale.scaleFactor = 1f; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeBy(32)
|
||||
val low = pixel(File(directory, "low.png"))
|
||||
rule.mainClock.advanceTimeBy(650)
|
||||
assertNotEquals(low, pixel(File(directory, "high.png")))
|
||||
assertEquals(bounds, rule.onNodeWithTag("shape").fetchSemanticsNode().boundsInRoot)
|
||||
} finally { directory.deleteRecursively() }
|
||||
}
|
||||
|
||||
@Test fun animatingSkeletonDoesNotPerformMainThreadDiskOrNetworkWork() {
|
||||
val loading = mutableStateOf(false)
|
||||
rule.setContent { WolfskinTheme { Box(Modifier.size(200.dp, 140.dp).skeletonPulse(loading.value).background(WolfskinTokens.Color.surface2)) } }
|
||||
rule.waitForIdle()
|
||||
var previous: StrictMode.ThreadPolicy? = null
|
||||
rule.runOnIdle {
|
||||
previous = StrictMode.getThreadPolicy()
|
||||
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectDiskReads().detectDiskWrites().detectNetwork().penaltyDeath().build())
|
||||
loading.value = true
|
||||
}
|
||||
try { rule.mainClock.advanceTimeBy(1500); rule.waitForIdle() }
|
||||
finally { rule.runOnIdle { StrictMode.setThreadPolicy(requireNotNull(previous)) } }
|
||||
}
|
||||
|
||||
private fun shell() = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") }
|
||||
private fun bounds() = listOf("title-overview", "title-actions", "title-shelf:episodes").map { rule.onNodeWithTag(it).fetchSemanticsNode().boundsInRoot }
|
||||
private fun pixel(file: File): Int {
|
||||
rule.waitForIdle()
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
return BitmapFactory.decodeFile(file.absolutePath).let { bitmap -> try { bitmap.getPixel(100, 100) } finally { bitmap.recycle() } }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import coil3.asImage
|
||||
import coil3.decode.DataSource
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.transition.CrossfadeTransition
|
||||
import coil3.transition.TransitionTarget
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Provenance, errors and reduced motion determine the consumer-visible artwork transition. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class NetworkArtTransitionTest {
|
||||
private val target = object : TransitionTarget {
|
||||
override val view: View get() = error("Compose transition factories must not read a View")
|
||||
override val drawable: Drawable? = null
|
||||
}
|
||||
|
||||
@Test fun onlyNetworkSuccessFadesAndReducedMotionCapsItsDuration() {
|
||||
val request = ImageRequest.Builder(RuntimeEnvironment.getApplication()).data("https://example.invalid/poster").build()
|
||||
val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888)
|
||||
try {
|
||||
for (reduced in listOf(false, true)) {
|
||||
NetworkArtTransition.reduced = reduced
|
||||
for (source in DataSource.entries) {
|
||||
val transition = NetworkArtTransition.create(target, SuccessResult(bitmap.asImage(), request, source))
|
||||
if (source == DataSource.NETWORK) {
|
||||
assertTrue(transition is CrossfadeTransition)
|
||||
assertEquals(if (reduced) 80 else 160, (transition as CrossfadeTransition).durationMillis)
|
||||
} else assertFalse("$source must paint instantly", transition is CrossfadeTransition)
|
||||
}
|
||||
assertFalse(NetworkArtTransition.create(target, ErrorResult(null, request, IllegalStateException("offline"))) is CrossfadeTransition)
|
||||
}
|
||||
} finally { NetworkArtTransition.reduced = false; bitmap.recycle() }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue