Merge remote-tracking branch 'origin/fm/tv-round2' into fm/tv-r2-home

This commit is contained in:
justinzeus 2026-11-07 18:02:26 +01:00
commit 33cca64eba
8 changed files with 159 additions and 23 deletions

View file

@ -44,20 +44,19 @@ class MotionSurfaceTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
private val viewer = ShellPerson("justin", "Justin", "J", false)
@Test fun lateSeriesReadinessAndRatingsLeaveActionsOverviewAndEpisodesAtTheSameCoordinates() {
@Test fun reducedMotionReadinessSnapsAndRemovingItRestoresTheCollapsedStack() {
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()
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides true) { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } }
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())
}
assertEquals(0f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f)
rule.runOnIdle { state.update(state.data.copy(requestedStage = "Ready", requestActor = "Mindel")) }
assertEquals(56f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f)
bounds().zip(before).forEach { (after, initial) -> assertEquals(56f, after.top - initial.top, 1f) }
rule.runOnIdle { state.update(state.data.copy(requestedStage = null)) }
assertEquals(before, bounds())
}
@Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() {

View file

@ -0,0 +1,100 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
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
/** Readiness changes move real title content continuously, without reserving an empty band. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleReadinessMotionTest {
private val animatorScale = object : MotionDurationScale { override var scaleFactor by mutableFloatStateOf(1f) }
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>(effectContext = animatorScale)
private lateinit var state: TitleState
private fun render() {
val viewer = ShellPerson("justin", "Justin", "J", false)
val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") }
val series = TitleItem("series", "A series", "tv", seasons = 1, held = true, overview = "A series overview")
val episode = TitleItem("episode", "Pilot", "episode", held = true, season = 1, episode = 1, overview = "Episode overview")
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.mainClock.advanceTimeBy(500)
rule.waitForIdle()
}
private fun positions(): List<Float> = listOf("title-readiness-slot", "title-overview", "title-actions", "title-shelf:episodes").mapIndexed { index, tag ->
val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot
if (index == 0) bounds.height else bounds.top
}
private fun frames(stage: String?, name: String, count: Int = 22): List<List<Float>> {
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-readiness-motion/$name").apply { mkdirs() }
rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Justin")); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
val values = List(count) { index ->
rule.waitForIdle()
positions().also {
rule.onRoot().captureRoboImage(File(directory, "${index.toString().padStart(2, '0')}.png").absolutePath)
rule.mainClock.advanceTimeBy(16)
rule.waitForIdle()
}
}
File(directory, "samples.csv").writeText("timeMs,slotHeightPx,overviewTopPx,actionsTopPx,episodesTopPx\n" + values.mapIndexed { index, row -> "${index * 16},${row.joinToString(",")}" }.joinToString("\n"))
return values
}
@Test fun lateReadinessExpansionAndRemovalStayMonotonicAndMoveTheWholeStack() {
render()
val collapsed = positions()
assertEquals(0f, collapsed[0], 0.1f)
rule.mainClock.autoAdvance = false
val opening = frames("Ready", "expand")
val expanded = opening.last()
assertEquals(56f, expanded[0], 0.1f)
expanded.drop(1).zip(collapsed.drop(1)).forEach { (after, before) -> assertEquals(56f, after - before, 1f) }
val closing = frames(null, "collapse")
assertEquals(collapsed, closing.last())
for ((samples, direction) in listOf(opening to 1f, closing to -1f)) {
samples.zipWithNext().forEach { (before, after) -> before.zip(after).forEach { (first, second) ->
assertTrue("Readiness content must not reverse or overshoot: $samples", (second - first) * direction >= -0.1f)
assertTrue("Height change must not jump in one frame", kotlin.math.abs(second - first) < 56f)
} }
samples.forEach { assertTrue(it[0] in 0f..56f) }
}
assertFalse(state.options.any { it.id == "seasons" })
rule.mainClock.autoAdvance = true
}
@Test fun aPinnedEpisodeShelfDoesNotBounceWhenReadinessChangesAboveIt() {
render()
rule.runOnIdle { state.focus("episodes", 0) }
rule.mainClock.advanceTimeBy(500)
rule.waitForIdle()
val pinned = positions().last()
rule.mainClock.autoAdvance = false
for (samples in listOf(frames("Ready", "pinned", count = 34), frames(null, "pinned-collapse", count = 34))) {
samples.forEach { assertEquals("A header change cannot bounce a pinned focused shelf", pinned, it.last(), 1f) }
}
assertEquals("episodes", state.row)
rule.mainClock.autoAdvance = true
}
}