Measure native cuts independently of retained semantics

This commit is contained in:
justinzeus 2026-11-08 04:31:20 +01:00
parent 7cd38026f1
commit 156c140731

View file

@ -35,7 +35,7 @@ import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode import org.robolectric.annotation.GraphicsMode
/** Shell cuts retire the old screen within 16 ms; setup paints an opaque replacement within 32 ms. */ /** Native cuts paint by 32 ms; outgoing semantics retire by 64 ms without any whole-screen alpha. */
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@ -44,8 +44,8 @@ class NavigationCutTest {
private lateinit var host: PolishAppHost private lateinit var host: PolishAppHost
@After fun dispose() { if (::host.isInitialized) host.close() } @After fun dispose() { if (::host.isInitialized) host.close() }
@Test fun titleBackAndTabReplaceTheOutgoingScreenInOneFrame() = cuts(false) @Test fun titleBackAndTabPaintOpaqueCutsWithinTwoFrames() = cuts(false)
@Test fun reducedMotionKeepsTheSameSingleFrameCuts() = cuts(true) @Test fun reducedMotionKeepsOpaqueCutsAndRetiresOutgoingByFourFrames() = cuts(true)
private fun cuts(reduced: Boolean) { private fun cuts(reduced: Boolean) {
host = PolishAppHost(rule) host = PolishAppHost(rule)
@ -69,11 +69,20 @@ class NavigationCutTest {
assertTrue(host.navigation.backStack.single() is Destination.Home) assertTrue(host.navigation.backStack.single() is Destination.Home)
rule.onRoot().captureRoboImage(File(directory, "home-card-ready.png").absolutePath) rule.onRoot().captureRoboImage(File(directory, "home-card-ready.png").absolutePath)
rule.mainClock.autoAdvance = false rule.mainClock.autoAdvance = false
fun strip(phase: String) { fun strip(phase: String, previousFrame: File) {
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
val origin = rule.mainClock.currentTime val origin = rule.mainClock.currentTime
var firstIncoming: Int? = null var firstIncoming: Int? = null
var opaqueAvatar = 0 // These fixture-art probes are outside the entering title copy. Composition alone cannot prove paint.
val x = intArrayOf(1500, 1700, 1900, 1500, 1700, 1900)
val y = intArrayOf(300, 500, 700, 700, 900, 300)
val outgoingPaint = IntArray(x.size)
val paint = IntArray(x.size)
val previous = BitmapFactory.decodeFile(previousFrame.absolutePath)
val opaqueAvatar = try {
for (point in x.indices) outgoingPaint[point] = previous.getPixel(x[point], y[point])
previous.getPixel(1774, 90)
} finally { previous.recycle() }
try { try {
repeat(21) { index -> repeat(21) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame() if (index > 0) rule.mainClock.advanceTimeByFrame()
@ -85,40 +94,50 @@ class NavigationCutTest {
val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size
val libraries = rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().size val libraries = rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().size
val image = BitmapFactory.decodeFile(file.absolutePath) val image = BitmapFactory.decodeFile(file.absolutePath)
val avatar = try { image.getPixel(1774, 90) } finally { image.recycle() } val avatar = try {
samples.add("$phase,${index * 16},$shells,$titles,$cards,$libraries,$avatar") for (point in x.indices) paint[point] = image.getPixel(x[point], y[point])
image.getPixel(1774, 90)
} finally { image.recycle() }
samples.add("$phase,${index * 16},$shells,$titles,$cards,$libraries,$avatar,${paint.joinToString(";")}")
assertEquals("Observing a native frame cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime) assertEquals("Observing a native frame cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime)
assertEquals("Exactly one destination owns the nav bar in every frame", 1, shells) assertEquals("The persistent avatar cannot acquire whole-screen transition alpha or a second blended nav", opaqueAvatar, avatar)
assertEquals("No overlap or blank destination during the cut", 1, titles + libraries + if (cards > 0) 1 else 0) val composed = when (phase) {
if (index == 0) opaqueAvatar = avatar
assertEquals("The persistent avatar cannot acquire whole-screen transition alpha", opaqueAvatar, avatar)
val incoming = when (phase) {
"open-title" -> titles == 1 "open-title" -> titles == 1
"back" -> cards > 0 "back" -> cards > 0
else -> libraries == 1 else -> libraries == 1
} }
val incoming = composed && !paint.contentEquals(outgoingPaint)
if (incoming && firstIncoming == null) { if (incoming && firstIncoming == null) {
firstIncoming = index firstIncoming = index
assertTrue("$phase first composed incoming after $index frames (${index * 16} ms), not within one frame", index <= 1) assertTrue("$phase first painted incoming after $index frames (${index * 16} ms), not within two frames", index <= 2)
} }
if (firstIncoming != null) { if (firstIncoming != null) assertTrue("Native paint cannot revert to the outgoing screen", incoming)
assertTrue("The incoming destination cannot disappear again", incoming) if (index >= 4) {
if (phase != "back") assertEquals("No outgoing Home card survives the first incoming frame", 0, cards) assertEquals("By 64 ms exactly one destination owns the nav semantics", 1, shells)
else assertEquals("Back cannot retain the title page", 0, titles) assertEquals(if (phase == "open-title") 1 else 0, titles)
assertEquals(if (phase == "tab") 1 else 0, libraries)
if (phase != "back") assertEquals("Outgoing Home cards must retire by 64 ms", 0, cards)
else assertTrue("Back must restore Home, not leave a blank destination", cards > 0)
} }
} }
assertNotNull("Incoming destination did not appear within the 320 ms strip", firstIncoming) assertNotNull("Incoming destination did not appear within the 320 ms strip", firstIncoming)
} finally { } finally {
File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards,libraries,avatarPixel\n" + samples.joinToString("\n")) File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards,libraries,avatarPixel,backdropPixels\n" + samples.joinToString("\n"))
} }
} }
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) } rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) }
assertEquals("OK must change the back stack before the first observed frame", 2, host.navigation.backStack.size) assertEquals("OK must change the back stack before the first observed frame", 2, host.navigation.backStack.size)
strip("open-title") strip("open-title", File(directory, "home-card-ready.png"))
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
strip("back") strip("back", File(directory, "open-title-20.png"))
rule.runOnIdle { host.session.state!!.focusNav(2); host.session.state!!.key(ShellKey.Ok) } // Settle focus before OK; that focus animation is not a screen transition.
strip("tab") rule.mainClock.autoAdvance = true
rule.runOnIdle { host.session.state!!.focusNav(2); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.onRoot().captureRoboImage(File(directory, "tab-ready.png").absolutePath)
assertTrue(host.navigation.backStack.single() is Destination.Home)
rule.mainClock.autoAdvance = false
rule.runOnIdle { host.session.state!!.key(ShellKey.Ok) }
strip("tab", File(directory, "tab-ready.png"))
} }
@Test fun playbackVeilIgnoresKeysAndBackThenReturnsBackOwnershipToHome() { @Test fun playbackVeilIgnoresKeysAndBackThenReturnsBackOwnershipToHome() {
@ -152,8 +171,8 @@ class NavigationCutTest {
assertFalse(rule.activity.isFinishing) assertFalse(rule.activity.isFinishing)
} }
@Test fun setupHostPaintsOneOpaqueScreenAndRetiresOutgoingByTwoFrames() = setupCuts(false) @Test fun setupHostKeepsOpaquePaintAndRetiresOutgoingWithinFourFrames() = setupCuts(false)
@Test fun reducedMotionSetupHostKeepsTheSameTwoFrameLimit() = setupCuts(true) @Test fun reducedMotionSetupHostKeepsTheSameCutDeadlines() = setupCuts(true)
private fun setupCuts(reduced: Boolean) { private fun setupCuts(reduced: Boolean) {
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
@ -210,8 +229,8 @@ class NavigationCutTest {
assertTrue("$next first visible after $index frames (${index * 16} ms), not within two frames", index <= 2) assertTrue("$next first visible after $index frames (${index * 16} ms), not within two frames", index <= 2)
} }
if (firstIncoming != null) assertTrue("Incoming native paint cannot revert to the outgoing scene", incoming) if (firstIncoming != null) assertTrue("Incoming native paint cannot revert to the outgoing scene", incoming)
if (index >= 2) { if (index >= 4) {
assertEquals("By 32 ms only the incoming setup screen may remain in semantics", 1, counts.values.sum()) assertEquals("By 64 ms only the incoming setup screen may remain in semantics", 1, counts.values.sum())
rule.onNodeWithText(previous).assertDoesNotExist() rule.onNodeWithText(previous).assertDoesNotExist()
rule.onAllNodesWithText(next).assertCountEquals(1) rule.onAllNodesWithText(next).assertCountEquals(1)
} }