Establish painted navigation and player-return preconditions
This commit is contained in:
parent
b4742bed6d
commit
7cd38026f1
2 changed files with 48 additions and 16 deletions
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue