Establish painted navigation and player-return preconditions

This commit is contained in:
justinzeus 2026-11-08 04:03:44 +01:00
parent b4742bed6d
commit 7cd38026f1
2 changed files with 48 additions and 16 deletions

View file

@ -130,9 +130,14 @@ class HomeHeroStartingTest {
assertNull(session.playback.starting) assertNull(session.playback.starting)
assertNull(session.playback.launch) assertNull(session.playback.launch)
coVerify(exactly = 1) { facade.preparePlayback(user.id, any()) } 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")) File(directory, "samples.csv").writeText("phase,timeMs,pixel,stackSize\n" + samples.joinToString("\n"))
rule.runOnIdle { navigation.goBack() } rule.runOnIdle { navigation.goBack(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeBy(100) rule.mainClock.advanceTimeByFrame()
rule.onRoot().captureRoboImage(File(directory, "home-return-first.png").absolutePath)
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed() rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
rule.onNodeWithText("Starting…").assertDoesNotExist() rule.onNodeWithText("Starting…").assertDoesNotExist()
} }

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
/** 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) @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)
@ -50,13 +50,25 @@ class NavigationCutTest {
private fun cuts(reduced: Boolean) { private fun cuts(reduced: Boolean) {
host = PolishAppHost(rule) host = PolishAppHost(rule)
host.mount(picker = false, reduced = reduced) 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 directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
val samples = mutableListOf<String>() val samples = mutableListOf<String>()
val homeCard = SemanticsMatcher("Home card") { val homeCard = SemanticsMatcher("Home card") {
it.config.contains(androidx.compose.ui.semantics.SemanticsProperties.TestTag) && it.config.contains(androidx.compose.ui.semantics.SemanticsProperties.TestTag) &&
it.config[androidx.compose.ui.semantics.SemanticsProperties.TestTag].startsWith("home-card:") 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) { fun strip(phase: String) {
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
@ -100,7 +112,8 @@ class NavigationCutTest {
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\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") strip("open-title")
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
strip("back") strip("back")
@ -139,8 +152,8 @@ class NavigationCutTest {
assertFalse(rule.activity.isFinishing) assertFalse(rule.activity.isFinishing)
} }
@Test fun setupHostReplacesServerAndUserScreensInOneFrame() = setupCuts(false) @Test fun setupHostPaintsOneOpaqueScreenAndRetiresOutgoingByTwoFrames() = setupCuts(false)
@Test fun reducedMotionSetupHostAlsoCutsInOneFrame() = setupCuts(true) @Test fun reducedMotionSetupHostKeepsTheSameTwoFrameLimit() = 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)
@ -161,9 +174,11 @@ class NavigationCutTest {
true true
}) })
} } } } } }
rule.mainClock.autoAdvance = false
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() } val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() }
val samples = mutableListOf<String>() val samples = mutableListOf<String>()
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)))) { for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) {
val previous = stack.single().javaClass.simpleName val previous = stack.single().javaClass.simpleName
val next = destination.javaClass.simpleName val next = destination.javaClass.simpleName
@ -178,19 +193,27 @@ class NavigationCutTest {
rule.waitForIdle() rule.waitForIdle()
val counts = colors.keys.associateWith { rule.onAllNodesWithText(it).fetchSemanticsNodes().size } val counts = colors.keys.associateWith { rule.onAllNodesWithText(it).fetchSemanticsNodes().size }
val image = BitmapFactory.decodeFile(file.absolutePath) 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") 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("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 = pixel == colors.getValue(next)
val incoming = counts.getValue(next) == 1 assertTrue("Setup native paint is exactly the outgoing or incoming scene, never an alpha composite",
assertEquals("Setup screens are opaque, not intermediate-alpha composites", colors.getValue(if (incoming) next else previous), pixel) incoming || pixel == colors.getValue(previous))
if (incoming && firstIncoming == null) { if (incoming && firstIncoming == null) {
firstIncoming = index 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) { if (firstIncoming != null) assertTrue("Incoming native paint cannot revert to the outgoing scene", incoming)
assertTrue("The incoming setup screen cannot disappear", 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.onNodeWithText(previous).assertDoesNotExist()
rule.onAllNodesWithText(next).assertCountEquals(1)
} }
} }
assertNotNull("Setup screen did not appear within 320 ms", firstIncoming) assertNotNull("Setup screen did not appear within 320 ms", firstIncoming)
@ -267,7 +290,11 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule<ActivitySc
} }
} }
await { session.state?.people?.size == users.size && model.signedInPerson == "justin" } await { session.state?.people?.size == users.size && model.signedInPerson == "justin" }
rule.runOnIdle { session.state!!.pickerVisible = picker; session.state!!.pickerLaunch = picker } rule.runOnIdle {
session.state!!.pickerVisible = picker
session.state!!.pickerLaunch = picker
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
}
rule.mainClock.advanceTimeBy(1200) rule.mainClock.advanceTimeBy(1200)
} }