From b4742bed6d04f5dcdaacd7e2ac29180000d91b68 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 03:36:19 +0100 Subject: [PATCH] Observe frame-zero cuts and keep picker masks opaque --- .../wolfskin/tv/HomeHeroStartingTest.kt | 47 +++++--- .../wolfskin/tv/NavigationCutTest.kt | 102 ++++++++++++++---- .../wolfskin/design/PersonCommit.kt | 5 +- 3 files changed, 116 insertions(+), 38 deletions(-) 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 c545c680..8f6868d7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt @@ -82,24 +82,34 @@ class HomeHeroStartingTest { private fun successfulStart(reduced: Boolean) { mount(reduced) - val width = rule.onNode(hasText("Resume", substring = true) and hasClickAction()).fetchSemanticsNode().boundsInRoot.width - press() - rule.mainClock.advanceTimeBy(32) - rule.onNodeWithText("Starting…").assertIsDisplayed() - assertEquals(width, rule.onNode(hasText("Starting…") and hasClickAction()).fetchSemanticsNode().boundsInRoot.width, 0f) - press() - assertTrue(navigation.backStack.single() is Destination.Home) val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/" + if (reduced) "reduced" else "normal").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath) + val width = rule.onNode(hasText("Resume", substring = true) and hasClickAction()).fetchSemanticsNode().boundsInRoot.width val samples = mutableListOf() fun frame(phase: String, index: Int): Int { val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png") + val clock = rule.mainClock.currentTime rule.onRoot().captureRoboImage(file.absolutePath) + rule.waitForIdle() + assertEquals("Native capture cannot warm the animation clock", clock, rule.mainClock.currentTime) val image = BitmapFactory.decodeFile(file.absolutePath) val pixel = try { image.getPixel(600, 500) } finally { image.recycle() } samples.add(phase + "," + index * 16 + "," + pixel + "," + navigation.backStack.size) return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) } - repeat(13) { frame("home-start", it); rule.mainClock.advanceTimeByFrame() } + press() + val origin = rule.mainClock.currentTime + repeat(14) { index -> + if (index > 0) rule.mainClock.advanceTimeByFrame() + frame("home-start", index) + assertEquals("Every frame after OK is observed, including frame zero", origin + index * 16L, rule.mainClock.currentTime) + if (index >= 2) { + rule.onNodeWithText("Starting…").assertIsDisplayed() + assertEquals(width, rule.onNode(hasText("Starting…") and hasClickAction()).fetchSemanticsNode().boundsInRoot.width, 0f) + } + if (index == 3) press() + assertTrue(navigation.backStack.single() is Destination.Home) + } val id = UUID.randomUUID() release.complete(BaseItemDto(id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE)) await { session.playback.launch != null } @@ -129,12 +139,20 @@ class HomeHeroStartingTest { @Test fun failedPreparationRestoresResumeAndShowsTheExistingRecoveryToast() { mount() + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/failure").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath) press() - rule.mainClock.advanceTimeBy(200) - rule.onNodeWithText("Starting…").assertIsDisplayed() + repeat(14) { index -> + if (index > 0) rule.mainClock.advanceTimeByFrame() + rule.onRoot().captureRoboImage(File(directory, "preparing-" + index.toString().padStart(2, '0') + ".png").absolutePath) + if (index >= 2) rule.onNodeWithText("Starting…").assertIsDisplayed() + } release.completeExceptionally(IOException("offline")) await { model.toast != null } - rule.mainClock.advanceTimeBy(200) + repeat(14) { index -> + rule.mainClock.advanceTimeByFrame() + rule.onRoot().captureRoboImage(File(directory, "restored-" + index.toString().padStart(2, '0') + ".png").absolutePath) + } rule.onNodeWithText("Resume", substring = true).assertIsDisplayed() rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed() assertTrue(navigation.backStack.single() is Destination.Home) @@ -143,6 +161,7 @@ class HomeHeroStartingTest { @Test fun leavingHomeCancelsPreparationAndCannotLaunchALateResult() { mount() press() + assertEquals("Leaving Home must cancel an actual pending start", "hero-play", session.playback.starting) rule.mainClock.advanceTimeBy(32) rule.runOnIdle { visible.value = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } rule.mainClock.advanceTimeBy(32) @@ -153,12 +172,14 @@ class HomeHeroStartingTest { assertNull(session.playback.launch) } - private fun press() = rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) } + private fun press() { + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) } + rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + } private fun await(condition: () -> Boolean) { rule.waitUntil(5000) { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() shadowOf(Looper.getMainLooper()).idle() - if (!rule.mainClock.autoAdvance) rule.mainClock.advanceTimeByFrame() condition() } rule.waitForIdle() 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 6f68f7ea..ce6d3a1e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt @@ -1,7 +1,9 @@ package org.justinvisser.wolfskin.tv import android.app.Application +import android.graphics.BitmapFactory import android.os.Looper +import androidx.compose.foundation.background import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.requiredSize import androidx.compose.runtime.* @@ -33,7 +35,7 @@ import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode -/** Each navigation change retires the outgoing shell in the first rendered frame. */ +/** Every observed native frame has one opaque screen; the incoming scene must compose within 16 ms. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -57,19 +59,45 @@ class NavigationCutTest { } fun strip(phase: String) { rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } - repeat(21) { index -> - rule.mainClock.advanceTimeByFrame() - val clock = rule.mainClock.currentTime - rule.onRoot().captureRoboImage(File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png").absolutePath) - assertEquals("Native drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime) - val shells = rule.onAllNodesWithTag("wolfskin-shell").fetchSemanticsNodes().size - val titles = rule.onAllNodesWithTag("title-page").fetchSemanticsNodes().size - val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size - assertEquals("Only the live destination may own a nav bar", 1, shells) - assertEquals(if (phase == "open-title") 1 else 0, titles) - if (phase != "back") assertEquals("Outgoing Home cards cannot survive the cut", 0, cards) - else assertTrue("Back immediately paints Home's shelves", cards > 0) - samples.add(phase + "," + index * 16 + "," + shells + "," + titles + "," + cards) + val origin = rule.mainClock.currentTime + var firstIncoming: Int? = null + var opaqueAvatar = 0 + try { + repeat(21) { index -> + if (index > 0) rule.mainClock.advanceTimeByFrame() + val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png") + rule.onRoot().captureRoboImage(file.absolutePath) + rule.waitForIdle() + val shells = rule.onAllNodesWithTag("wolfskin-shell").fetchSemanticsNodes().size + val titles = rule.onAllNodesWithTag("title-page").fetchSemanticsNodes().size + 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") + 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) { + "open-title" -> titles == 1 + "back" -> cards > 0 + else -> libraries == 1 + } + if (incoming && firstIncoming == null) { + firstIncoming = index + assertTrue("$phase first composed incoming after $index frames (${index * 16} ms), not within one frame", index <= 1) + } + 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) + } + } + 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")) } } rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L); pressKey(Key.DirectionCenter, 0L) } @@ -78,7 +106,6 @@ class NavigationCutTest { strip("back") rule.runOnIdle { host.session.state!!.focusNav(2); host.session.state!!.key(ShellKey.Ok) } strip("tab") - File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards\n" + samples.joinToString("\n")) } @Test fun playbackVeilIgnoresKeysAndBackThenReturnsBackOwnershipToHome() { @@ -119,6 +146,7 @@ class NavigationCutTest { val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null) val stack = mutableStateListOf(SetupDestination.ServerList) + val colors = mapOf("ServerList" to 0xff304060.toInt(), "UserList" to 0xff406030.toInt(), "AppContent" to 0xff603040.toInt()) val screensaver = mockk() every { screensaver.state } returns kotlinx.coroutines.flow.MutableStateFlow(ScreensaverState(false, false, false, false)) rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { androidx.tv.material3.MaterialTheme { @@ -126,21 +154,49 @@ class NavigationCutTest { mockk(relaxed = true), screensaver, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), setupInterceptor = { destination, modifier -> - androidx.tv.material3.Text(destination.javaClass.simpleName, modifier) + androidx.compose.foundation.layout.Box(modifier.then(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) + .then(Modifier.background(androidx.compose.ui.graphics.Color(colors.getValue(destination.javaClass.simpleName))))) { + androidx.tv.material3.Text(destination.javaClass.simpleName) + } 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() for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) { val previous = stack.single().javaClass.simpleName + val next = destination.javaClass.simpleName rule.runOnIdle { stack[0] = destination; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } - rule.mainClock.advanceTimeByFrame() - val clock = rule.mainClock.currentTime - val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, destination.javaClass.simpleName + ".png").absolutePath) - assertEquals("Native setup drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime) - rule.onNodeWithText(previous).assertDoesNotExist() - rule.onAllNodesWithText(destination.javaClass.simpleName).assertCountEquals(1) + val origin = rule.mainClock.currentTime + var firstIncoming: Int? = null + try { + repeat(21) { index -> + if (index > 0) rule.mainClock.advanceTimeByFrame() + val file = File(directory, next + "-" + index.toString().padStart(2, '0') + ".png") + rule.onRoot().captureRoboImage(file.absolutePath) + 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() } + 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) + if (incoming && firstIncoming == null) { + firstIncoming = index + assertTrue("$next first composed after $index frames (${index * 16} ms), not within one frame", index <= 1) + } + if (firstIncoming != null) { + assertTrue("The incoming setup screen cannot disappear", incoming) + rule.onNodeWithText(previous).assertDoesNotExist() + } + } + assertNotNull("Setup screen did not appear within 320 ms", firstIncoming) + } finally { + File(directory, "samples.csv").writeText("destination,timeMs,previous,incoming,pixel\n" + samples.joinToString("\n")) + } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt index 19a74027..aae4fb84 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt @@ -50,6 +50,9 @@ fun PersonCommitVeil(commit: PersonCommit, modifier: Modifier = Modifier) { .drawWithContent { drawContent() val elapsed = commit.elapsedMillis + val full = if (commit.timelineComplete) 1f else if (reduced) elapsed / duration else (elapsed - duration) / duration + drawRect(C.bg.copy(alpha = M.ease.transform(full.coerceIn(0f, 1f)))) + // Paint the opaque phase-one masks last: phase two must not round their background pixels upward. if (!reduced) { val alpha = M.ease.transform((elapsed / duration).coerceIn(0f, 1f)) val width = L.pickerAvatar.toPx() @@ -61,8 +64,6 @@ fun PersonCommitVeil(commit: PersonCommit, modifier: Modifier = Modifier) { size = Size(width + gap, S.pickerGearTop.toPx() - S.pickerPeopleTop.toPx() + gap / 2)) } } - val full = if (commit.timelineComplete) 1f else if (reduced) elapsed / duration else (elapsed - duration) / duration - drawRect(C.bg.copy(alpha = M.ease.transform(full.coerceIn(0f, 1f)))) }) { Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker)) PickerPeople(commit.people, commit.chosenIndex, false, onPerson = null, onGear = null)