From 79f64f6e18aba3eff2938831061d1d53532ba98e Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 18:44:00 +0100 Subject: [PATCH] Restore sequential hero copy and first-appearance entry --- data/tv-spec/spec.md | 8 +- .../wolfskin/design/HeroMotion.kt | 90 +++++++----- .../design/DiscoverHeroSequenceTest.kt | 47 +++++-- .../wolfskin/design/HeroMotionTest.kt | 2 +- .../wolfskin/design/HomeHeroSequenceTest.kt | 132 ++++++++++++++++++ 5 files changed, 226 insertions(+), 53 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroSequenceTest.kt diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 9ced0894..7de1c44a 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -66,16 +66,16 @@ Home heroes and shelves, Library rows, Discover hero/lanes, title headers, episo | Poster caption in / out | 60 ms delay + 180 ms ease-out / 100 ms ease-out | 10 px rise on entry only; exit holds its position, including when entry was interrupted | | Network artwork only | 160 ms, Coil painter alpha | None; memory, disk and local hits are instant | | Row/tab content reveal | 180 ms, ease-out | One layer per row, 24 px rise | -| Initial title copy | 180 ms, ease-out | 16 px rise; already-loaded Home/Discover hero copy paints immediately | +| Initial hero/title copy | 180 ms, ease-out | 16 px rise (`copyEntryRise`); saved hero entry state suppresses replay on remount, not first appearance | | Hero backdrop hand-off | 320 ms, ease-in-out (`.65,0,.35,1`) | Alpha only; retain previous art until incoming art or its final fallback is ready | -| Hero copy hand-off | Incoming and outgoing alpha counter-fade over 200 ms ease-out, after artwork or final fallback is ready | 14 px rise for incoming copy only; interrupted outgoing copy holds its current translation | +| Hero copy hand-off | Outgoing 120 ms ease (`.25,.1,.25,1`); incoming starts after 140 ms and enters over 200 ms ease-out, gated on artwork or final fallback readiness | One copy slot: no overlapping text; 14 px incoming rise, outgoing translation held where entry stopped | | Late ratings, genres, overview and readiness | 200 ms, ease | Alpha only, inside existing measured slots; rating plates keep their existing plate fade rather than adding a nested fade | | Horizontal / vertical focus-follow scroll | 160 ms ease / 180 ms ease-out | Existing scroll geometry | | Discover pager | 180 ms, ease-out, retargeting current width and opacity | Buttons and pager remain outside the copy transition; reduced motion snaps width and caps opacity fades at 80 ms | Row reveals are grouped rather than the browser prototype's per-card staging: the armv7 frame budget permits one animated layer per shelf, not one per card. Tabs have no full-screen transition layer. The upper Home hero blend is bounded to **1920×820 px**, Discover to **1920×740 px**; the entire painted backdrop stays static while incoming artwork loads, and the lower backdrop changes only when artwork or its final fallback is ready, without an animated screen-sized layer. Hero copy is a separate bounded block with stationary action controls. Ordinary image reveal uses Coil's painter; hero-controlled requests bypass that independent fade so only the token-backed hero blend owns the transition. Existing poster-only blur is static. -The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. An ordinary hero hand-off animates **3** bounded owners (outgoing art and two copy blocks) above an opaque artwork base. Interruptions retain the painted composite without resetting alpha; each outgoing owner keeps its own original 320 ms artwork / 200 ms copy lifetime rather than restarting with later presses. Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; ordinary artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen. +The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. An ordinary hero hand-off animates **2** bounded owners (outgoing art and one sequential copy block) above an opaque artwork base. Art interruptions retain the painted composite without resetting alpha; each outgoing art owner keeps its original 320 ms lifetime. Copy retargets its current alpha, fades out before replacing text, then uses the approved delay/entry tokens; never two copy layers are visible together. Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; ordinary artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen. The approved title follow-up (2026-11-07) supersedes the fixed readiness reservation: an absent readiness line occupies **0 px**, while a present line occupies **56 px (28 dp)**. Late arrival/removal animates that height over the **200 ms late-line duration with token ease-out**; reduced motion snaps. Overview, actions and following rows move monotonically with that band; an already-pinned focused row stays pinned through same-remeasure scroll compensation. Ratings remain inside the fixed-height facts band and appear once: an explicit episode page uses its opened episode's own set, including the episode score, rather than the series-only set. Series and episode pages have no redundant Seasons shortcut; Down reaches the season chips. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial title/person loading retains destination focus rather than focusing the root nav; Down cannot enter a skeleton, and Back still pops the route without opening the picker. An already-ready catalogue or held title stays Ready while a same-route, same-person refresh reads new data; the existing signed-in authentication guard remains authoritative, and loaded copy/rows do not remount or replay their entry motion. @@ -85,7 +85,7 @@ Discover key traversal skips pending lanes in both directions. If every remainin Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target. -The [round-2 Discover native proof](https://pages.justinvisser.org/wolfskin/tv-round2-discover/) publishes paired 1920×1080 frame strips and exact 16 ms alpha/translation/position series for ready, slow, failed, sustained rapid-selection and reduced-motion scenarios. Both versions use identical fixture data, immediate key-down/up, and the same selection timestamps. The regression suite checks an opaque artwork base, visible copy composite, monotonic per-layer transitions, stationary controls and token-bounded outgoing lifetimes. These are native-rendering proofs, not on-box frame-pacing measurements. +The [round-2 Discover native proof](https://pages.justinvisser.org/wolfskin/tv-round2-discover/) publishes paired 1920×1080 frame strips and exact 16 ms alpha/translation/position series for ready, slow, failed, sustained rapid-selection and reduced-motion scenarios. The reviewed counter-fade remains available separately for comparison with the corrected sequential copy; initial-entry/remount and Home hero series are also published. The regression suite checks an opaque artwork base, at most one visible copy, monotonic per-phase motion, stationary controls and token-bounded outgoing artwork lifetimes. The approved fade-through may briefly have no visible copy; it never paints overlapping titles. These are native-rendering proofs, not on-box frame-pacing measurements. ## Card metadata and bottom state line — revision 6, option B diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt index 44dc46a7..3d46efa0 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HeroMotion.kt @@ -15,6 +15,8 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -27,6 +29,9 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M @@ -55,11 +60,7 @@ private class HeroLayer(val identity: Any, var target: T, initialAlpha: Float var outgoing by mutableStateOf(false) } -private class HeroCopyLayer(val identity: Any, var target: T, initialAlpha: Float) { - val alpha = Animatable(initialAlpha) - val progress = Animatable(initialAlpha) - var outgoing by mutableStateOf(false) -} +private class HeroCopyTarget(val identity: Any, var target: T) /** Keep the painted composite while loading; interruptions fade each retained layer from its current alpha. */ @Composable @@ -102,42 +103,59 @@ fun HeroArtCrossfade(identity: Any, target: T, height: Dp, modifier: Modifie } } -/** Copy counter-fades after artwork is ready; outgoing blocks hold their current translation. */ +/** One copy slot fades through after artwork readiness; no two titles can be painted together. */ @Composable fun HeroCopyHandoff(identity: Any, target: T, modifier: Modifier = Modifier, content: @Composable (T) -> Unit) { - val initial = remember { HeroCopyLayer(identity, target, 1f) } - val layers = remember { mutableStateListOf(initial) } - val selected = remember(identity) { layers.lastOrNull { it.identity == identity && !it.outgoing } ?: HeroCopyLayer(identity, target, 0f) } + var entered by rememberSaveable { mutableStateOf(false) } + val selected = remember(identity) { HeroCopyTarget(identity, target) } + val currentSelected by rememberUpdatedState(selected) + var displayed by remember { mutableStateOf(selected) } + val alpha = remember { Animatable(if (entered) 1f else 0f) } + val progress = remember { Animatable(if (entered) 1f else 0f) } + var initialEntry by remember { mutableStateOf(!entered) } + var handingOff by remember { mutableStateOf(false) } + var outgoing by remember { mutableStateOf(false) } val reduced = reducedMotion() val presentation = LocalHeroPresentation.current - val rise = with(LocalDensity.current) { M.copyRise.toPx() } - LaunchedEffect(selected, reduced) { - if (selected !in layers) { - if (presentation != null) snapshotFlow { presentation.identity == identity }.first { it } - layers.add(selected) - layers.forEach { if (it !== selected) it.outgoing = true } - } - } - Box(modifier, contentAlignment = Alignment.BottomStart) { - layers.forEach { layer -> key(layer) { - SideEffect { if (layer.identity == identity) layer.target = target } - LaunchedEffect(layer.outgoing, reduced) { - if (layer.outgoing) { - layer.alpha.animateTo(0f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) - layers.remove(layer) - } else if (layer.alpha.value != 1f) coroutineScope { - launch { layer.alpha.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) } - launch { layer.progress.animateTo(1f, tween(motionDuration(M.heroCopyIn, reduced), easing = M.easeOut)) } + val rise = with(LocalDensity.current) { (if (initialEntry) M.copyEntryRise else M.copyRise).toPx() } + SideEffect { selected.target = target } + LaunchedEffect(presentation, reduced) { + snapshotFlow { currentSelected.takeIf { presentation == null || presentation.identity == it.identity } } + .filterNotNull() + .distinctUntilChangedBy { it.identity } + .collectLatest { next -> + if (!entered) { + displayed = next + entered = true + } else if (displayed.identity != next.identity || handingOff) { + handingOff = true + outgoing = true + coroutineScope { + val fade = if (alpha.value > 0f) launch { + alpha.animateTo(0f, tween(motionDuration(M.heroCopyOut, reduced), easing = M.ease)) + } else null + if (!reduced) kotlinx.coroutines.delay(M.heroCopyDelay.toLong()) + fade?.join() + } + outgoing = false + displayed = next + initialEntry = false + progress.snapTo(0f) } + if (alpha.value != 1f || progress.value != 1f) coroutineScope { + val duration = motionDuration(if (initialEntry) M.rowEnter else M.heroCopyIn, reduced) + launch { alpha.animateTo(1f, tween(duration, easing = M.easeOut)) } + launch { progress.animateTo(1f, tween(duration, easing = M.easeOut)) } + } + handingOff = false } - Box(Modifier.graphicsLayer { - alpha = layer.alpha.value - translationY = if (reduced) 0f else rise * (1f - layer.progress.value) - }.testTag(if (layer === layers.last()) "hero-copy" else "hero-copy-outgoing").semantics { - this[HeroLayerAlpha] = layer.alpha.value - this[HeroLayerTranslation] = if (reduced) 0f else rise * (1f - layer.progress.value) - this[HeroLayerIdentity] = layer.identity.toString() - }) { content(if (layer.identity == identity) target else layer.target) } - } } } + Box(modifier.graphicsLayer { + this.alpha = alpha.value + translationY = if (reduced) 0f else rise * (1f - progress.value) + }.testTag(if (outgoing) "hero-copy-outgoing" else "hero-copy").semantics { + this[HeroLayerAlpha] = alpha.value + this[HeroLayerTranslation] = if (reduced) 0f else rise * (1f - progress.value) + this[HeroLayerIdentity] = displayed.identity.toString() + }) { content(if (displayed.identity == identity) target else displayed.target) } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt index 68b49c7a..193a1348 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverHeroSequenceTest.kt @@ -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>() private val previousDotWidths = mutableMapOf() 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>() 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 diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt index 01f96558..f316001a 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HeroMotionTest.kt @@ -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) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroSequenceTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroSequenceTest.kt new file mode 100644 index 00000000..899c36af --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroSequenceTest.kt @@ -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() + 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)) + } +}