diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt index 8f6868d7..9fffeed4 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt @@ -130,9 +130,14 @@ class HomeHeroStartingTest { assertNull(session.playback.starting) assertNull(session.playback.launch) coVerify(exactly = 1) { facade.preparePlayback(user.id, any()) } + // Returning from the player requires a painted handoff first, not a pop in the same unrendered turn. + rule.mainClock.advanceTimeByFrame() + rule.onRoot().captureRoboImage(File(directory, "home-disposed.png").absolutePath) + rule.onAllNodesWithTag("wolfskin-shell").assertCountEquals(0) File(directory, "samples.csv").writeText("phase,timeMs,pixel,stackSize\n" + samples.joinToString("\n")) - rule.runOnIdle { navigation.goBack() } - rule.mainClock.advanceTimeBy(100) + rule.runOnIdle { navigation.goBack(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeByFrame() + rule.onRoot().captureRoboImage(File(directory, "home-return-first.png").absolutePath) rule.onNodeWithText("Resume", substring = true).assertIsDisplayed() rule.onNodeWithText("Starting…").assertDoesNotExist() } 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 ce6d3a1e..5c62575e 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 -/** Every observed native frame has one opaque screen; the incoming scene must compose within 16 ms. */ +/** Shell cuts retire the old screen within 16 ms; setup paints an opaque replacement within 32 ms. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -50,13 +50,25 @@ class NavigationCutTest { private fun cuts(reduced: Boolean) { host = PolishAppHost(rule) host.mount(picker = false, reduced = reduced) - rule.mainClock.autoAdvance = false val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/" + if (reduced) "reduced" else "normal").apply { mkdirs() } val samples = mutableListOf() val homeCard = SemanticsMatcher("Home card") { it.config.contains(androidx.compose.ui.semantics.SemanticsProperties.TestTag) && it.config[androidx.compose.ui.semantics.SemanticsProperties.TestTag].startsWith("home-card:") } + // Establish a painted Home and a focused shelf card before starting the navigation clock. + rule.onRoot().captureRoboImage(File(directory, "home-precondition.png").absolutePath) + rule.onNodeWithText("Who’s watching?").assertDoesNotExist() + rule.runOnIdle { host.session.state!!.focusNav(0); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + repeat(2) { + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L) } + rule.waitForIdle() + } + assertFalse("Home card activation cannot start from a picker", host.session.state!!.pickerVisible) + assertNotNull("The OK precondition is a shelf card, not the nav or hero", host.session.state!!.contentFocus?.cardKey) + 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) { rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } val origin = rule.mainClock.currentTime @@ -100,7 +112,8 @@ class NavigationCutTest { File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards,libraries,avatarPixel\n" + samples.joinToString("\n")) } } - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L); 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) strip("open-title") rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } strip("back") @@ -139,8 +152,8 @@ class NavigationCutTest { assertFalse(rule.activity.isFinishing) } - @Test fun setupHostReplacesServerAndUserScreensInOneFrame() = setupCuts(false) - @Test fun reducedMotionSetupHostAlsoCutsInOneFrame() = setupCuts(true) + @Test fun setupHostPaintsOneOpaqueScreenAndRetiresOutgoingByTwoFrames() = setupCuts(false) + @Test fun reducedMotionSetupHostKeepsTheSameTwoFrameLimit() = setupCuts(true) private fun setupCuts(reduced: Boolean) { val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) @@ -161,9 +174,11 @@ class NavigationCutTest { true }) } } } - rule.mainClock.autoAdvance = false val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() } val samples = mutableListOf() + rule.onRoot().captureRoboImage(File(directory, "setup-precondition.png").absolutePath) + rule.onAllNodesWithText("ServerList").assertCountEquals(1) + rule.mainClock.autoAdvance = false for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) { val previous = stack.single().javaClass.simpleName val next = destination.javaClass.simpleName @@ -178,19 +193,27 @@ class NavigationCutTest { rule.waitForIdle() val counts = colors.keys.associateWith { rule.onAllNodesWithText(it).fetchSemanticsNodes().size } val image = BitmapFactory.decodeFile(file.absolutePath) - val pixel = try { image.getPixel(600, 500) } finally { image.recycle() } + val pixel = try { + val sample = image.getPixel(600, 500) + assertEquals("The entire setup scene must share its opaque paint", sample, image.getPixel(100, 500)) + assertEquals(sample, image.getPixel(1500, 500)) + assertEquals(sample, image.getPixel(600, 900)) + sample + } finally { image.recycle() } samples.add("$next,${index * 16},${counts.getValue(previous)},${counts.getValue(next)},$pixel") assertEquals("Native setup observation cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime) - assertEquals("One setup screen per observed frame, never both or blank", 1, counts.values.sum()) - val incoming = counts.getValue(next) == 1 - assertEquals("Setup screens are opaque, not intermediate-alpha composites", colors.getValue(if (incoming) next else previous), pixel) + val incoming = pixel == colors.getValue(next) + assertTrue("Setup native paint is exactly the outgoing or incoming scene, never an alpha composite", + incoming || pixel == colors.getValue(previous)) if (incoming && firstIncoming == null) { firstIncoming = index - assertTrue("$next first composed after $index frames (${index * 16} ms), not within one frame", index <= 1) + assertTrue("$next first visible after $index frames (${index * 16} ms), not within two frames", index <= 2) } - if (firstIncoming != null) { - assertTrue("The incoming setup screen cannot disappear", incoming) + 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()) rule.onNodeWithText(previous).assertDoesNotExist() + rule.onAllNodesWithText(next).assertCountEquals(1) } } assertNotNull("Setup screen did not appear within 320 ms", firstIncoming) @@ -267,7 +290,11 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule