Measure native cuts independently of retained semantics
This commit is contained in:
parent
7cd38026f1
commit
156c140731
1 changed files with 45 additions and 26 deletions
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue