diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt index 5c62575e..443a7258 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt @@ -35,7 +35,7 @@ import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config 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) @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -44,8 +44,8 @@ class NavigationCutTest { private lateinit var host: PolishAppHost @After fun dispose() { if (::host.isInitialized) host.close() } - @Test fun titleBackAndTabReplaceTheOutgoingScreenInOneFrame() = cuts(false) - @Test fun reducedMotionKeepsTheSameSingleFrameCuts() = cuts(true) + @Test fun titleBackAndTabPaintOpaqueCutsWithinTwoFrames() = cuts(false) + @Test fun reducedMotionKeepsOpaqueCutsAndRetiresOutgoingByFourFrames() = cuts(true) private fun cuts(reduced: Boolean) { host = PolishAppHost(rule) @@ -69,11 +69,20 @@ class NavigationCutTest { assertTrue(host.navigation.backStack.single() is Destination.Home) rule.onRoot().captureRoboImage(File(directory, "home-card-ready.png").absolutePath) rule.mainClock.autoAdvance = false - fun strip(phase: String) { + fun strip(phase: String, previousFrame: File) { rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } val origin = rule.mainClock.currentTime 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 { repeat(21) { index -> if (index > 0) rule.mainClock.advanceTimeByFrame() @@ -85,40 +94,50 @@ class NavigationCutTest { val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size val libraries = rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().size val image = BitmapFactory.decodeFile(file.absolutePath) - val avatar = try { image.getPixel(1774, 90) } finally { image.recycle() } - samples.add("$phase,${index * 16},$shells,$titles,$cards,$libraries,$avatar") + val avatar = try { + 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("Exactly one destination owns the nav bar in every frame", 1, shells) - assertEquals("No overlap or blank destination during the cut", 1, titles + libraries + if (cards > 0) 1 else 0) - if (index == 0) opaqueAvatar = avatar - assertEquals("The persistent avatar cannot acquire whole-screen transition alpha", opaqueAvatar, avatar) - val incoming = when (phase) { + assertEquals("The persistent avatar cannot acquire whole-screen transition alpha or a second blended nav", opaqueAvatar, avatar) + val composed = when (phase) { "open-title" -> titles == 1 "back" -> cards > 0 else -> libraries == 1 } + val incoming = composed && !paint.contentEquals(outgoingPaint) if (incoming && firstIncoming == null) { 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) { - assertTrue("The incoming destination cannot disappear again", incoming) - if (phase != "back") assertEquals("No outgoing Home card survives the first incoming frame", 0, cards) - else assertEquals("Back cannot retain the title page", 0, titles) + if (firstIncoming != null) assertTrue("Native paint cannot revert to the outgoing screen", incoming) + if (index >= 4) { + assertEquals("By 64 ms exactly one destination owns the nav semantics", 1, shells) + 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) } 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) } 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() } - strip("back") - rule.runOnIdle { host.session.state!!.focusNav(2); host.session.state!!.key(ShellKey.Ok) } - strip("tab") + strip("back", File(directory, "open-title-20.png")) + // Settle focus before OK; that focus animation is not a screen transition. + 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() { @@ -152,8 +171,8 @@ class NavigationCutTest { assertFalse(rule.activity.isFinishing) } - @Test fun setupHostPaintsOneOpaqueScreenAndRetiresOutgoingByTwoFrames() = setupCuts(false) - @Test fun reducedMotionSetupHostKeepsTheSameTwoFrameLimit() = setupCuts(true) + @Test fun setupHostKeepsOpaquePaintAndRetiresOutgoingWithinFourFrames() = setupCuts(false) + @Test fun reducedMotionSetupHostKeepsTheSameCutDeadlines() = setupCuts(true) private fun setupCuts(reduced: Boolean) { 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) } if (firstIncoming != null) assertTrue("Incoming native paint cannot revert to the outgoing scene", incoming) - if (index >= 2) { - assertEquals("By 32 ms only the incoming setup screen may remain in semantics", 1, counts.values.sum()) + if (index >= 4) { + assertEquals("By 64 ms only the incoming setup screen may remain in semantics", 1, counts.values.sum()) rule.onNodeWithText(previous).assertDoesNotExist() rule.onAllNodesWithText(next).assertCountEquals(1) }