Match native updater frames and preserve D-pad focus behavior

This commit is contained in:
justinzeus 2026-11-07 17:55:09 +01:00
parent 2b8c729f62
commit 37ad87d058
16 changed files with 131 additions and 67 deletions

View file

@ -29,11 +29,11 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Native TV frames use the real shell, updater controller seam and Android key adapter. */
/** Native D-pad behavior preserves the retained shell, action focus and real scroller. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class UpdatesCaptureTest {
class UpdatesKeysTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
pickerVisible = false
@ -78,41 +78,24 @@ class UpdatesCaptureTest {
}
private fun press(keys: List<Key>) {
keys.forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
keys.forEach { key ->
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
}
rule.mainClock.advanceTimeBy(320)
rule.waitForIdle()
}
@Test fun settings() { launch(UpdateUiState.Checking, settings = true); capture("settings") }
@Test fun classic() {
@Test fun classicRequiresEveryRetainedRowAndTheRealScroller() {
shell.settingsRow = 0
launch(UpdateUiState.Checking, settings = true)
repeat(8) { press(listOf(Key.DirectionDown)) }
repeat(9) { press(listOf(Key.DirectionDown)) }
assertEquals(9, shell.settingsRow)
assertEquals(4, shell.view.listState.firstVisibleItemIndex)
capture("classic")
}
@Test fun checking() { launch(UpdateUiState.Checking); capture("checking") }
@Test fun current() { launch(UpdateUiState.UpToDate); capture("current") }
@Test fun available() { launch(updateReleaseFixture()); capture("available") }
@Test fun notes() { launch(updateReleaseFixture()); press(listOf(Key.DirectionRight)); capture("notes") }
@Test fun notesOpen() { launch(updateReleaseFixture()); press(listOf(Key.DirectionRight, Key.DirectionCenter)); capture("notes-open") }
@Test fun downloading() { launch(UpdateUiState.Downloading(20_100_000, 47_800_000)); capture("downloading") }
@Test fun ready() { launch(UpdateUiState.Ready("1.1.1")); capture("ready") }
@Test fun permission() { launch(UpdateUiState.PermissionNeeded); capture("permission") }
@Test fun handoff() { launch(UpdateUiState.HandingOff); capture("handoff") }
@Test fun cancelled() { launch(UpdateUiState.InstallCancelled); capture("cancelled") }
@Test fun failed() { launch(UpdateUiState.Failed(UpdateFailure.HttpRefused, "The download server refused the request (403).")); capture("failed") }
@Test fun offline() { launch(UpdateUiState.Offline); capture("offline") }
@Test fun noInstaller() { launch(UpdateUiState.NoInstaller); capture("no-installer") }
@Test fun noReleasePublished() { launch(UpdateUiState.NoReleasePublished); capture("no-release") }
@Test fun unknownDownloadLength() { launch(UpdateUiState.Downloading(20_100_000, null)); capture("unknown-total") }
@Test fun autoOn() { launch(updateReleaseFixture()); press(listOf(Key.DirectionDown)); capture("auto-on") }
@Test fun autoOff() { launch(updateReleaseFixture()); press(listOf(Key.DirectionDown, Key.DirectionCenter)); capture("auto-off") }
@Test fun updated() {
launch(UpdateUiState.UpToDate)
rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) }
rule.mainClock.advanceTimeBy(320)
capture("updated")
press(listOf(Key.DirectionCenter))
assertEquals("classic", shell.handOff)
}
@Test fun notesTrapRealKeysAndBackPreservesSettingsRowAndScroller() {
@ -141,6 +124,19 @@ class UpdatesCaptureTest {
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
}
@Test fun navAndAutoCheckReturnToTheRememberedActionWithoutToggling() {
launch(updateReleaseFixture())
press(listOf(Key.DirectionRight, Key.DirectionDown, Key.DirectionUp))
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
assertTrue(controller.autoCheck.value)
press(listOf(Key.DirectionUp))
assertTrue(shell.navFocused)
press(listOf(Key.DirectionDown))
assertFalse(shell.navFocused)
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
assertTrue(controller.autoCheck.value)
}
@Test fun refreshingInstalledIdentityCannotMaskAnOngoingCheck() {
launch(UpdateUiState.UpToDate)
rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) }
@ -150,7 +146,7 @@ class UpdatesCaptureTest {
rule.onNodeWithTag("update-progress").assertIsDisplayed()
}
@Test fun nativeProgressAndStateTransitionAtSixteenMillisecondFrames() {
@Test fun progressKeepsAutoCheckFocusAcrossByteAndReducedMotionChanges() {
launch(UpdateUiState.Checking)
val target = directory
if (target != null) {
@ -162,7 +158,6 @@ class UpdatesCaptureTest {
rule.runOnIdle { controller.show(UpdateUiState.Downloading(20_100_000, 47_800_000)) }
rule.mainClock.advanceTimeByFrame()
repeat(24) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/state-%03d".format(frame)) }
assertEquals(12f, rule.onNodeWithTag("update-progress").fetchSemanticsNode().boundsInRoot.height, .01f)
if (target != null) {
repeat(101) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/download-%03d".format(frame)) }
}