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

This commit is contained in:
justinzeus 2026-11-07 00:57:13 +01:00
parent fd48426fa7
commit 1668445418
86 changed files with 2026 additions and 121 deletions

View file

@ -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() }
}
}

View file

@ -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() } }
}
}

View file

@ -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() }
}
}