Match native updater frames and preserve D-pad focus behavior
This commit is contained in:
parent
2b8c729f62
commit
37ad87d058
16 changed files with 131 additions and 67 deletions
|
|
@ -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)) }
|
||||
}
|
||||
|
|
@ -39,7 +39,6 @@ class UpdateFocus(
|
|||
}
|
||||
|
||||
fun focusAction(index: Int) { actionIndex = index; autoFocused = false }
|
||||
fun focusAuto() { autoFocused = true }
|
||||
|
||||
fun activate(index: Int) {
|
||||
focusAction(index)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -28,8 +27,14 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.Role
|
||||
|
|
@ -47,7 +52,6 @@ import org.justinvisser.wolfskin.design.PickerButton
|
|||
import org.justinvisser.wolfskin.design.ShellState
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||
import org.justinvisser.wolfskin.design.exactLineBox
|
||||
import org.justinvisser.wolfskin.design.focusRing
|
||||
import org.justinvisser.wolfskin.design.focusScale
|
||||
import org.justinvisser.wolfskin.design.motionDuration
|
||||
import org.justinvisser.wolfskin.design.reducedMotion
|
||||
|
|
@ -76,10 +80,11 @@ fun UpdatesScreen(shell: ShellState, controller: UpdateController, onBack: () ->
|
|||
val scope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
val reduced = reducedMotion()
|
||||
val scrollDuration by rememberUpdatedState(motionDuration(M.scroll, reduced))
|
||||
val focus = remember(controller, shell) {
|
||||
UpdateFocus(controller, onBack, onClassic, { shell.navFocused = true; shell.navIndex = shell.currentTab }) { direction ->
|
||||
scope.launch { scroll.animateScrollBy(with(density) { U.notesScrollStep.toPx() } * direction,
|
||||
tween(motionDuration(M.scroll, reduced), easing = M.easeOut)) }
|
||||
tween(scrollDuration, easing = M.easeOut)) }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state, presentation.actions) { focus.update(state, presentation.actions) }
|
||||
|
|
@ -168,7 +173,18 @@ private fun NotesPanel(release: UpdateUiState.Available?, scroll: androidx.compo
|
|||
val paragraphs = remember(release?.notes) { release?.notes?.lineSequence()?.filter { it.isNotBlank() }?.toList().orEmpty() }
|
||||
val notesStyle = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal)
|
||||
CssColumn(Modifier.offset(L.safeSide, U.contentTop).size(U.width, U.notesHeight).testTag("update-notes")
|
||||
.focusRing(true, U.radius, WolfskinTokens.Focus.ringControlRing)
|
||||
.drawWithCache {
|
||||
val ring = WolfskinTokens.Focus.ringControlRing.toPx() / 2f
|
||||
val radius = U.radius.toPx()
|
||||
val inset = U.border.toPx() + ring / 2f
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawRoundRect(C.accent, Offset(-ring / 2f, -ring / 2f), Size(size.width + ring, size.height + ring),
|
||||
CornerRadius(radius + ring / 2f), style = Stroke(ring))
|
||||
drawRoundRect(C.accent, Offset(inset, inset), Size(size.width - inset * 2, size.height - inset * 2),
|
||||
CornerRadius(radius - inset), style = Stroke(ring))
|
||||
}
|
||||
}
|
||||
.background(C.surface, shape).border(U.border, C.border, shape)
|
||||
.verticalScroll(scroll).padding(U.padding + U.border)) {
|
||||
Text(updateMetadata(release), Modifier.exactLineBox(T.stateTitle), style = T.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = C.fg)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue