Polish picker switching and Home playback handoff

This commit is contained in:
justinzeus 2026-11-08 03:16:09 +01:00
parent 74b737fb52
commit 612ae79f0c
20 changed files with 974 additions and 83 deletions

View file

@ -0,0 +1,63 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
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
/** Loading profiles share one pulse without fading the recovery action. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class PickerPolishTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun loadingAvatarsBreatheButtonStays() = capture(false)
@Test fun reducedMotionKeepsAvatarsAndButtonStill() = capture(true)
private fun capture(reduced: Boolean) {
rule.mainClock.autoAdvance = false
val state = ShellState(fixturePeople(), "justin").apply { peopleStatus = PeopleStatus.Loading }
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
} } }
rule.mainClock.advanceTimeBy(32)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/P07/${if (reduced) "reduced" else "normal"}").apply { mkdirs() }
val samples = (0..14).map { index ->
val file = File(directory, "picker-loading-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val image = BitmapFactory.decodeFile(file.absolutePath)
val pixels = try { image.getPixel(650, 540) to image.getPixel(950, 820) } finally { image.recycle() }
rule.mainClock.advanceTimeBy(100, ignoreFrameDuration = true)
"${index * 100},${pixels.first},${pixels.second}"
}
File(directory, "samples.csv").writeText("timeMs,avatar,button\n" + samples.joinToString("\n"))
val avatars = samples.map { it.split(',')[1].toInt() }
val buttons = samples.map { it.split(',')[2].toInt() }
assertEquals("The recovery button never pulses", 1, buttons.distinct().size)
if (reduced) assertEquals("Reduced motion stops the shared pulse", 1, avatars.distinct().size)
else {
assertNotEquals("The avatar must breathe by the middle of the cycle", avatars.first(), avatars[7])
assertNotEquals("The avatar must also pass through an intermediate alpha", avatars.first(), avatars[3])
}
rule.runOnIdle { state.peopleStatus = PeopleStatus.Ready; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeBy(1000)
rule.onNodeWithTag("picker-person:justin").assertIsDisplayed()
rule.onNodeWithText("Continue as Justin").assertDoesNotExist()
assertTrue("Removing the last subscriber stops its infinite transition",
androidx.compose.runtime.Recomposer.runningRecomposers.value.all { !it.hasPendingWork })
}
}