Round 2 candidate 2: merge origin/fm/tv-back-title
This commit is contained in:
commit
d739a201ee
27 changed files with 196 additions and 77 deletions
|
|
@ -480,6 +480,8 @@ dependencies {
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
dependsOn(":wolfskin-design:testDebugUnitTest")
|
dependsOn(":wolfskin-design:testDebugUnitTest")
|
||||||
|
// Android 16 shared-memory setup needs Robolectric's JDK FileDescriptor bridge.
|
||||||
|
jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED")
|
||||||
systemProperty("robolectric.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath)
|
systemProperty("robolectric.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath)
|
||||||
systemProperty("robolectric.offline", "true")
|
systemProperty("robolectric.offline", "true")
|
||||||
systemProperty("roborazzi.test.record", "true")
|
systemProperty("roborazzi.test.record", "true")
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class DiscoverSkeletonKeysTest {
|
||||||
}
|
}
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
rule.runOnIdle { assertEquals(1, state.focusedRow); assertNull(state.cardKey) }
|
rule.runOnIdle { assertEquals(1, state.focusedRow); assertNull(state.cardKey) }
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible); assertEquals("discover", shell.route) }
|
rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible); assertEquals("discover", shell.route) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ class DiscoverTitleTimingTest {
|
||||||
assertEquals("related", model.title!!.row)
|
assertEquals("related", model.title!!.row)
|
||||||
rule.onNodeWithText("More like this").assertIsDisplayed()
|
rule.onNodeWithText("More like this").assertIsDisplayed()
|
||||||
rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath)
|
rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath)
|
||||||
rule.onRoot().performKeyInput { pressKey(Key.Back) }
|
rule.onRoot().performKeyInput { pressKey(Key.Escape) }
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertNull(discover.title)
|
assertNull(discover.title)
|
||||||
assertEquals("discover", shell.route)
|
assertEquals("discover", shell.route)
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ class HomeSeeAllNavigationTest {
|
||||||
try {
|
try {
|
||||||
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||||
exerciseGrid(grid)
|
exerciseGrid(grid)
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertTrue(navigation.backStack.last() is Destination.Home)
|
assertTrue(navigation.backStack.last() is Destination.Home)
|
||||||
assertEquals(row.id, snapshot.rows[shell.view.row].id)
|
assertEquals(row.id, snapshot.rows[shell.view.row].id)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
|
@ -47,6 +48,7 @@ import java.util.UUID
|
||||||
class TitleAsyncFocusTest {
|
class TitleAsyncFocusTest {
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
private lateinit var viewModel: WolfskinTitleViewModel
|
private lateinit var viewModel: WolfskinTitleViewModel
|
||||||
|
private val navigation = NavigationManager()
|
||||||
|
|
||||||
@After fun dispose() {
|
@After fun dispose() {
|
||||||
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
|
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
|
||||||
|
|
@ -58,9 +60,10 @@ class TitleAsyncFocusTest {
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.readSaved(id, any()) } returns null
|
coEvery { facade.readSaved(id, any()) } returns null
|
||||||
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
|
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
|
||||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
|
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
|
||||||
val person = ShellPerson("justin", "Justin", "J", false)
|
val person = ShellPerson("justin", "Justin", "J", false)
|
||||||
val route = titleRoute(id.toString())
|
val route = titleRoute(id.toString())
|
||||||
|
navigation.navigateTo(Destination.Wolfskin(route))
|
||||||
val shell = ShellState(listOf(person), person.key).apply {
|
val shell = ShellState(listOf(person), person.key).apply {
|
||||||
pickerVisible = false
|
pickerVisible = false
|
||||||
pickerLaunch = false
|
pickerLaunch = false
|
||||||
|
|
@ -100,9 +103,10 @@ class TitleAsyncFocusTest {
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.readPersonSaved(id) } returns null
|
coEvery { facade.readPersonSaved(id) } returns null
|
||||||
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
|
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
|
||||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
|
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
|
||||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
val route = personRoute(id.toString())
|
val route = personRoute(id.toString())
|
||||||
|
navigation.navigateTo(Destination.Wolfskin(route))
|
||||||
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
||||||
pickerVisible = false
|
pickerVisible = false
|
||||||
pickerLaunch = false
|
pickerLaunch = false
|
||||||
|
|
@ -146,9 +150,10 @@ class TitleAsyncFocusTest {
|
||||||
reads.incrementAndGet()
|
reads.incrementAndGet()
|
||||||
throw java.io.IOException("offline")
|
throw java.io.IOException("offline")
|
||||||
}
|
}
|
||||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
|
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
|
||||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
val route = titleRoute(id.toString())
|
val route = titleRoute(id.toString())
|
||||||
|
navigation.navigateTo(Destination.Wolfskin(route))
|
||||||
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
||||||
pickerVisible = false; pickerLaunch = false; enterDestination(route)
|
pickerVisible = false; pickerLaunch = false; enterDestination(route)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.os.Build
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.activity.BackEventCompat
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.snapshots.Snapshot
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import java.io.File
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.navigation3.runtime.NavEntry
|
||||||
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
|
@ -29,10 +35,12 @@ import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.Shadows
|
import org.robolectric.Shadows
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
/** A loaded outgoing destination remains composed after pop but cannot consume the incoming Home Back. */
|
/** Real NavDisplay and system/predictive Back must pop one title/person entry before the root ladder. */
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
class TitleEntryBackTest {
|
class TitleEntryBackTest {
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
private lateinit var viewModel: WolfskinTitleViewModel
|
private lateinit var viewModel: WolfskinTitleViewModel
|
||||||
|
|
@ -45,13 +53,50 @@ class TitleEntryBackTest {
|
||||||
|
|
||||||
@Test fun outgoingLoadedTitleCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = false)
|
@Test fun outgoingLoadedTitleCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = false)
|
||||||
@Test fun outgoingLoadedPersonCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = true)
|
@Test fun outgoingLoadedPersonCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = true)
|
||||||
|
@Test fun predictiveTitleBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = false, predictive = true)
|
||||||
|
@Test fun predictivePersonBackPopsOnlyOneEntry() = verifyOutgoingBack(personPage = true, predictive = true)
|
||||||
|
|
||||||
private fun verifyOutgoingBack(personPage: Boolean) {
|
@Test fun nativeBackKeyWaitsForSystemCompletionBeforePoppingTheTitle() {
|
||||||
|
val (navigation, shell) = mountTitle(personPage = false)
|
||||||
|
val before = navigation.backStack.toList()
|
||||||
|
rule.runOnIdle { rule.activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK)) }
|
||||||
|
assertEquals("Compose must not pop on native Back key-down before the system completes Back", before, navigation.backStack.toList())
|
||||||
|
// Predictive completion is a platform callback, not a raw key-up delivered to Compose.
|
||||||
|
dispatchBack(predictive = true)
|
||||||
|
rule.mainClock.advanceTimeBy(400)
|
||||||
|
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||||
|
assertFalse(rule.activity.isFinishing)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
val captures = File(System.getProperty("wolfskin.captureDirectory"), "system-back").apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-title-home.png").absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun immediateSecondSystemBackUsesHomeBeforeExitRecomposition() {
|
||||||
|
val (navigation, shell) = mountTitle(personPage = false)
|
||||||
|
rule.runOnIdle {
|
||||||
|
rule.activity.onBackPressedDispatcher.onBackPressed()
|
||||||
|
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||||
|
rule.activity.onBackPressedDispatcher.onBackPressed()
|
||||||
|
assertTrue("The next Back belongs to Home even before Compose retires the title handler", shell.navFocused)
|
||||||
|
assertFalse(rule.activity.isFinishing)
|
||||||
|
}
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
assertFalse(rule.activity.isFinishing)
|
||||||
|
dispatchBack(predictive = false)
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
assertTrue(rule.activity.isFinishing)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mountTitle(personPage: Boolean): Pair<NavigationManager, ShellState> {
|
||||||
val activity = rule.activity
|
val activity = rule.activity
|
||||||
val navigation = NavigationManager()
|
val navigation = NavigationManager()
|
||||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
val shell = ShellState(listOf(viewer), viewer.key) {
|
val shell = ShellState(listOf(viewer), viewer.key) {
|
||||||
if (it == ShellAction.Exit) navigation.requestExit()
|
when (it) {
|
||||||
|
ShellAction.Exit -> navigation.requestExit()
|
||||||
|
ShellAction.Pop -> navigation.goBack()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
}.apply { pickerVisible = false; pickerLaunch = false; navFocused = false }
|
}.apply { pickerVisible = false; pickerLaunch = false; navFocused = false }
|
||||||
val id = UUID.fromString("00000000-0000-0000-0000-000000000031")
|
val id = UUID.fromString("00000000-0000-0000-0000-000000000031")
|
||||||
val route = if (personPage) personRoute(id.toString()) else titleRoute(id.toString())
|
val route = if (personPage) personRoute(id.toString()) else titleRoute(id.toString())
|
||||||
|
|
@ -74,16 +119,18 @@ class TitleEntryBackTest {
|
||||||
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
WolfskinTheme {
|
WolfskinTheme {
|
||||||
rememberWolfskinBackHandler(shell, navigation)
|
val onBack = rememberWolfskinBackHandler(shell, navigation)
|
||||||
val current = navigation.backStack.last()
|
NavDisplay(backStack = navigation.backStack, onBack = onBack, entryProvider = { entry ->
|
||||||
LaunchedEffect(current) { if (current is Destination.Home) shell.enterDestination("home") }
|
NavEntry(entry) {
|
||||||
Box {
|
val active = navigation.backStack.lastOrNull() == entry
|
||||||
ScreenEntry(shell, "home", current is Destination.Home) { HomeScreen(shell, home) {} }
|
val entryRoute = if (entry is Destination.Home) "home" else route
|
||||||
// Retain the real outgoing destination just as NavDisplay does during its exit animation.
|
LaunchedEffect(entry, active) { if (active) shell.enterDestination(entryRoute) }
|
||||||
ScreenEntry(shell, route, current == destination) {
|
ScreenEntry(shell, entryRoute, active) {
|
||||||
TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = false)
|
if (entry is Destination.Home) ShellSurface(shell) { modifier -> HomeScreen(shell, home, modifier) {} }
|
||||||
|
else TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rule.waitUntil(5_000) {
|
rule.waitUntil(5_000) {
|
||||||
|
|
@ -91,17 +138,37 @@ class TitleEntryBackTest {
|
||||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
(if (personPage) viewModel.person != null else viewModel.title?.data?.status == TitleReadStatus.Ready) && !shell.navFocused
|
(if (personPage) viewModel.person != null else viewModel.title?.data?.status == TitleReadStatus.Ready) && !shell.navFocused
|
||||||
}
|
}
|
||||||
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
|
rule.waitForIdle()
|
||||||
|
return navigation to shell
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifyOutgoingBack(personPage: Boolean, predictive: Boolean = false) {
|
||||||
|
val activity = rule.activity
|
||||||
|
val (navigation, shell) = mountTitle(personPage)
|
||||||
|
dispatchBack(predictive)
|
||||||
|
rule.mainClock.advanceTimeBy(400)
|
||||||
|
assertFalse("One system Back from a title/person must never finish the task", activity.isFinishing)
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||||
assertFalse(shell.navFocused)
|
assertFalse(shell.navFocused)
|
||||||
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
|
dispatchBack(predictive)
|
||||||
rule.mainClock.advanceTimeBy(16)
|
rule.mainClock.advanceTimeBy(16)
|
||||||
assertTrue("The incoming Home must receive Back while the outgoing entry is retained", shell.navFocused)
|
assertTrue("The incoming Home must receive Back while the outgoing entry is retained", shell.navFocused)
|
||||||
assertFalse("A second Back must not finish the task through the outgoing entry", activity.isFinishing)
|
assertFalse("A second Back must not finish the task through the outgoing entry", activity.isFinishing)
|
||||||
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||||
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
|
dispatchBack(predictive)
|
||||||
rule.mainClock.advanceTimeBy(16)
|
rule.mainClock.advanceTimeBy(16)
|
||||||
assertTrue("Only the following Back from Home nav should exit", activity.isFinishing)
|
assertTrue("Only the following Back from Home nav should exit", activity.isFinishing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun dispatchBack(predictive: Boolean) {
|
||||||
|
rule.runOnIdle {
|
||||||
|
val dispatcher = rule.activity.onBackPressedDispatcher
|
||||||
|
if (predictive) {
|
||||||
|
dispatcher.dispatchOnBackStarted(BackEventCompat(0f, 0f, 0f, BackEventCompat.EDGE_LEFT))
|
||||||
|
dispatcher.dispatchOnBackProgressed(BackEventCompat(100f, 0f, .5f, BackEventCompat.EDGE_LEFT))
|
||||||
|
}
|
||||||
|
dispatcher.onBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ class TitleKeysTest {
|
||||||
assertEquals("Version", state.options[state.column].label)
|
assertEquals("Version", state.options[state.column].label)
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
assertEquals(1, state.sheetIndex)
|
assertEquals(1, state.sheetIndex)
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
assertNull(state.sheet)
|
assertNull(state.sheet)
|
||||||
assertEquals("Version", state.options[state.column].label)
|
assertEquals("Version", state.options[state.column].label)
|
||||||
assertFalse(shell.navFocused)
|
assertFalse(shell.navFocused)
|
||||||
|
|
@ -161,7 +161,7 @@ class TitleKeysTest {
|
||||||
assertEquals(2, state.subtitleIndex)
|
assertEquals(2, state.subtitleIndex)
|
||||||
press(Key.DirectionUp); press(Key.DirectionCenter)
|
press(Key.DirectionUp); press(Key.DirectionCenter)
|
||||||
assertEquals(-1, state.subtitleIndex)
|
assertEquals(-1, state.subtitleIndex)
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
assertNull(state.sheet)
|
assertNull(state.sheet)
|
||||||
assertEquals("tracks", state.options[state.column].id)
|
assertEquals("tracks", state.options[state.column].id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ class TitleRound2Test {
|
||||||
render()
|
render()
|
||||||
menu()
|
menu()
|
||||||
val opener = state.column
|
val opener = state.column
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
assertNull(state.sheet)
|
assertNull(state.sheet)
|
||||||
assertEquals(opener, state.column)
|
assertEquals(opener, state.column)
|
||||||
assertFalse(shell.navFocused)
|
assertFalse(shell.navFocused)
|
||||||
|
|
@ -64,7 +64,7 @@ class TitleRound2Test {
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
assertEquals(TitleSheet.MediaInfo, state.sheet)
|
assertEquals(TitleSheet.MediaInfo, state.sheet)
|
||||||
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
assertEquals(TitleSheet.Menu, state.sheet)
|
assertEquals(TitleSheet.Menu, state.sheet)
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
|
|
@ -115,7 +115,7 @@ class TitleRound2Test {
|
||||||
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
|
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
|
||||||
menu()
|
menu()
|
||||||
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
assertEquals("seasons", state.row)
|
assertEquals("seasons", state.row)
|
||||||
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
|
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.os.Build
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.runtime.snapshots.Snapshot
|
import androidx.compose.runtime.snapshots.Snapshot
|
||||||
import androidx.compose.ui.input.key.Key
|
|
||||||
import androidx.compose.ui.test.*
|
import androidx.compose.ui.test.*
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
|
@ -16,6 +16,8 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import java.io.File
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
|
@ -35,7 +37,7 @@ import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
/** Actual Wolfskin NavDisplay and Android Back finish the activity instead of exposing upstream setup. */
|
/** Actual Wolfskin NavDisplay and Android Back finish the activity instead of exposing upstream setup. */
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
class WolfskinRootExitTest {
|
class WolfskinRootExitTest {
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
@ -73,11 +75,18 @@ class WolfskinRootExitTest {
|
||||||
@Test fun hardwareBackUsesTheSameContentNavExitLadder() {
|
@Test fun hardwareBackUsesTheSameContentNavExitLadder() {
|
||||||
mount()
|
mount()
|
||||||
val shell = session.state!!
|
val shell = session.state!!
|
||||||
rule.runOnIdle { shell.pickerVisible = false; shell.pickerLaunch = false }
|
rule.runOnIdle { shell.pickerVisible = false; shell.pickerLaunch = false; shell.navFocused = false; shell.view.row = 1 }
|
||||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) }
|
rule.runOnIdle { activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK)) }
|
||||||
|
assertFalse("Native Back key-down must not run ahead of system Back completion", shell.navFocused)
|
||||||
|
rule.runOnIdle { activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK)) }
|
||||||
assertTrue(shell.navFocused)
|
assertTrue(shell.navFocused)
|
||||||
assertFalse(activity.isFinishing)
|
assertFalse(activity.isFinishing)
|
||||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) }
|
val captures = File(System.getProperty("wolfskin.captureDirectory"), "system-back").apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(captures, "sdk${Build.VERSION.SDK_INT}-home-nav.png").absolutePath)
|
||||||
|
rule.runOnIdle {
|
||||||
|
activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK))
|
||||||
|
activity.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK))
|
||||||
|
}
|
||||||
rule.mainClock.advanceTimeBy(400)
|
rule.mainClock.advanceTimeBy(400)
|
||||||
assertTrue(activity.isFinishing)
|
assertTrue(activity.isFinishing)
|
||||||
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,12 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List<TitleItem>) =
|
private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List<TitleItem>) =
|
||||||
PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } }
|
PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } }
|
||||||
|
|
||||||
|
/** A callback retained during exit must hand Back to the live destination, not pop its old title again. */
|
||||||
|
internal inline fun handleBack(route: String, shell: ShellState, onCurrentEntry: () -> Unit) {
|
||||||
|
if (navigation.backStack.last().toWolfskinRoute() == route) onCurrentEntry()
|
||||||
|
else handleWolfskinBack(shell, navigation)
|
||||||
|
}
|
||||||
|
|
||||||
private fun handle(action: TitleAction) {
|
private fun handle(action: TitleAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
TitleAction.Pop -> navigation.goBack()
|
TitleAction.Pop -> navigation.goBack()
|
||||||
|
|
@ -349,7 +355,9 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen
|
||||||
}
|
}
|
||||||
val person = viewModel.person.takeIf { title != null }
|
val person = viewModel.person.takeIf { title != null }
|
||||||
BackHandler(enabled = isScreenEntryActive() && title != null) {
|
BackHandler(enabled = isScreenEntryActive() && title != null) {
|
||||||
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
|
viewModel.handleBack(route, shell) {
|
||||||
|
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Box(modifier.onPreviewKeyEvent { event ->
|
Box(modifier.onPreviewKeyEvent { event ->
|
||||||
if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let {
|
if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let {
|
||||||
|
|
|
||||||
|
|
@ -314,12 +314,7 @@ fun WolfskinApp(
|
||||||
// Cached entries must not retain the launch shell after sign-in completes.
|
// Cached entries must not retain the launch shell after sign-in completes.
|
||||||
val state = LocalShellState.current
|
val state = LocalShellState.current
|
||||||
val discover = LocalDiscoverState.current
|
val discover = LocalDiscoverState.current
|
||||||
val route = when (destination) {
|
val route = destination.toWolfskinRoute()
|
||||||
is Destination.Home -> "home"
|
|
||||||
is Destination.Wolfskin -> destination.route
|
|
||||||
is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString())
|
|
||||||
else -> "pushed"
|
|
||||||
}
|
|
||||||
val isCurrent = navigationManager.backStack.lastOrNull() == destination
|
val isCurrent = navigationManager.backStack.lastOrNull() == destination
|
||||||
LaunchedEffect(destination, state, isCurrent) {
|
LaunchedEffect(destination, state, isCurrent) {
|
||||||
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
|
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.findActivity
|
import com.github.damontecres.wholphin.ui.findActivity
|
||||||
import org.justinvisser.wolfskin.design.ShellKey
|
import org.justinvisser.wolfskin.design.ShellKey
|
||||||
import org.justinvisser.wolfskin.design.ShellState
|
import org.justinvisser.wolfskin.design.ShellState
|
||||||
|
|
@ -18,11 +19,28 @@ internal fun rememberWolfskinBackHandler(shell: ShellState, navigation: Navigati
|
||||||
navigation.exitRequests.collect { activity.finishAndRemoveTask() }
|
navigation.exitRequests.collect { activity.finishAndRemoveTask() }
|
||||||
}
|
}
|
||||||
val onBack: () -> Unit = remember(shell, navigation) {
|
val onBack: () -> Unit = remember(shell, navigation) {
|
||||||
{
|
{ handleWolfskinBack(shell, navigation) }
|
||||||
if (navigation.backStack.lastOrNull()?.fullScreen == true) navigation.goBack()
|
|
||||||
else shell.key(ShellKey.Back)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
return onBack
|
return onBack
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Back reads navigation synchronously; an outgoing entry may outlive the composition that disabled it. */
|
||||||
|
internal fun handleWolfskinBack(shell: ShellState, navigation: NavigationManager) {
|
||||||
|
val destination = navigation.backStack.last()
|
||||||
|
if (destination.fullScreen) {
|
||||||
|
navigation.goBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val route = destination.toWolfskinRoute()
|
||||||
|
if (shell.route != route) shell.enterDestination(route)
|
||||||
|
shell.key(ShellKey.Back)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigation and Back use the same route identity for each rendered entry. */
|
||||||
|
internal fun Destination.toWolfskinRoute(): String = when (this) {
|
||||||
|
is Destination.Home -> "home"
|
||||||
|
is Destination.Wolfskin -> route
|
||||||
|
is Destination.MediaItem -> if (type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(itemId.toString()) else titleRoute(itemId.toString())
|
||||||
|
else -> "pushed"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ Source — Justin (2026-11-07): “the settings icon moved, but pressing down on
|
||||||
|
|
||||||
Root content Back moves to the nav; the next Back **finishes and removes Wolfskin’s Android task**, returning to the launcher without visiting an old Home/setup interface. This applies to Home, Discover, Movies, Shows and Anime. Cold-launch picker Back exits; a reopened picker first dismisses to its opener’s nav. Pushed title/person/settings/Updates pages pop their parent stack, and menus/sheets/unsaved-settings prompts get first refusal. A singleton full-screen page exits rather than becoming a dead end.
|
Root content Back moves to the nav; the next Back **finishes and removes Wolfskin’s Android task**, returning to the launcher without visiting an old Home/setup interface. This applies to Home, Discover, Movies, Shows and Anime. Cold-launch picker Back exits; a reopened picker first dismisses to its opener’s nav. Pushed title/person/settings/Updates pages pop their parent stack, and menus/sheets/unsaved-settings prompts get first refusal. A singleton full-screen page exits rather than becoming a dead end.
|
||||||
|
|
||||||
|
Native Android Back (`KEYCODE_BACK`) belongs exclusively to the activity’s system dispatcher, including Navigation 3 predictive completion; Compose key previews must not also navigate on its raw key-down. Escape remains the existing local keyboard Back shortcut. Each completed system Back has one consumer. A retained title/person callback checks the live navigation entry at invocation and hands Back to the incoming entry if its own entry has already popped; it does not depend on an enabled flag catching up with the exit animation. Root handling synchronizes the shell route to the current entry before applying content → nav → task exit. Back ownership is exercised on both SDK 34 (Android 14) and SDK 36.
|
||||||
|
|
||||||
Source — Justin (2026-11-07): “pressing back enough times should close wolfskin, currently there is no way to go back to projectivity apart from weird methods via the old ui”.
|
Source — Justin (2026-11-07): “pressing back enough times should close wolfskin, currently there is no way to go back to projectivity apart from weird methods via the old ui”.
|
||||||
|
|
||||||
## Focus visibility
|
## Focus visibility
|
||||||
|
|
|
||||||
|
|
@ -258,12 +258,19 @@ fall through to the older setup/user UI.
|
||||||
Source — Justin: “pressing back enough times should close wolfskin, currently
|
Source — Justin: “pressing back enough times should close wolfskin, currently
|
||||||
there is no way to go back to projectivity apart from weird methods via the old ui”.
|
there is no way to go back to projectivity apart from weird methods via the old ui”.
|
||||||
|
|
||||||
`rememberWolfskinBackHandler` owns Android Back at the app root, including a
|
The activity's `OnBackPressedDispatcher` owns native `KEYCODE_BACK`. It is not
|
||||||
single-entry `NavDisplay`; full-screen hand-offs use the same `NavigationManager`
|
mapped by `readShellKey`: a Compose key-down cannot navigate before the system
|
||||||
exit event rather than exposing the parent setup stack. `WolfskinRootExitTest`
|
Back callback commits. Escape remains the local keyboard Back shortcut.
|
||||||
mounts actual Wolfskin Home/Discover/library surfaces and covers picker,
|
`rememberWolfskinBackHandler` resolves the live navigation entry and synchronizes
|
||||||
hardware Back and Coming-in sheet precedence. `WolfskinBackStackTest` mounts
|
the shell route before the root nav/exit ladder. A title/person callback retained
|
||||||
the real Updates and Home Settings pages to cover parent pops and unsaved edits.
|
during exit forwards to that live entry instead of popping its old title again;
|
||||||
|
correctness does not depend on a recomposed enabled flag.
|
||||||
|
`TitleEntryBackTest` mounts real `NavDisplay`, Home and loaded title/person
|
||||||
|
surfaces; `WolfskinRootExitTest` mounts actual Home/Discover/library surfaces.
|
||||||
|
Both run on SDK 34 and 36, including system/predictive callbacks, native key
|
||||||
|
ownership, immediate successive Back before recomposition, picker and Coming-in
|
||||||
|
precedence. `WolfskinBackStackTest` keeps the real Updates/Home Settings parent
|
||||||
|
pops and unsaved-edit protection.
|
||||||
|
|
||||||
Saved Jellyfin identities use the existing upstream session. Conductarr supplies
|
Saved Jellyfin identities use the existing upstream session. Conductarr supplies
|
||||||
household labels, requester capabilities and library audiences after sign-in.
|
household labels, requester capabilities and library audiences after sign-in.
|
||||||
|
|
@ -373,21 +380,26 @@ the actual host's suspended fixture load and presses Android OK: it must open th
|
||||||
hero rather than activate the nav. Fixture completion does not steal a different
|
hero rather than activate the nav. Fixture completion does not steal a different
|
||||||
destination or nav selection.
|
destination or nav selection.
|
||||||
|
|
||||||
Native Roborazzi captures use Robolectric SDK 34 and
|
Native Roborazzi captures normally use Robolectric SDK 34 and
|
||||||
`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and
|
`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). System-Back smokes also run SDK 36.
|
||||||
the sampled Compose nav-spring curve to `build/wolfskin-captures/`.
|
`make test` writes capture PNGs and the sampled Compose nav-spring curve to
|
||||||
|
`build/wolfskin-captures/`.
|
||||||
Home, Library and Discover captures use `home/`, `library/` and `discover/`
|
Home, Library and Discover captures use `home/`, `library/` and `discover/`
|
||||||
subdirectories so shared spec state numbers cannot overwrite another renderer's image.
|
subdirectories so shared spec state numbers cannot overwrite another renderer's image.
|
||||||
Key replays exercise the Android event adapter, including the 6/8/10/11-press shell paths.
|
Key replays exercise the Android event adapter, including the 6/8/10/11-press shell paths.
|
||||||
The profile hand-off capture targets the real `HomeSettingsPage`; Playback is a
|
The profile hand-off capture targets the real `HomeSettingsPage`; Playback is a
|
||||||
separate press-count path.
|
separate press-count path.
|
||||||
|
|
||||||
Robolectric SDK 28/30/34 runtimes resolve through Gradle's existing persistent
|
Robolectric SDK 28/30/34/36 runtimes resolve through Gradle's existing persistent
|
||||||
dependency cache, then copy to `wolfskin-design/build/robolectric-sdk/`. Test workers
|
dependency cache, then copy to `wolfskin-design/build/robolectric-sdk/`. Test workers
|
||||||
use that local directory in offline mode instead of downloading Android JARs into
|
use that local directory in offline mode instead of downloading Android JARs into
|
||||||
the container's ephemeral home. A cache miss still needs Maven Central; rate-limited
|
the container's ephemeral home. A cache miss still needs Maven Central; rate-limited
|
||||||
hosts can seed the existing cache from SHA512-verified local Maven artifacts without
|
hosts can seed the existing cache from SHA512-verified local Maven artifacts without
|
||||||
changing the wrapper or adding volumes.
|
changing the wrapper or adding volumes.
|
||||||
|
SDK 36 Back tests use the pinned Android 16 runtime and a test-worker-only
|
||||||
|
`--add-exports=java.base/jdk.internal.access=ALL-UNNAMED` for Robolectric's
|
||||||
|
`ApplicationSharedMemory` / JDK FileDescriptor bridge. Other capture suites
|
||||||
|
retain their existing SDK selection.
|
||||||
|
|
||||||
Default-flavour launcher artwork and English, Hungarian and Polish string overrides
|
Default-flavour launcher artwork and English, Hungarian and Polish string overrides
|
||||||
live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated
|
live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ dependencies {
|
||||||
robolectricSdk("org.robolectric:android-all-instrumented:9-robolectric-4913185-2-i7")
|
robolectricSdk("org.robolectric:android-all-instrumented:9-robolectric-4913185-2-i7")
|
||||||
robolectricSdk("org.robolectric:android-all-instrumented:11-robolectric-6757853-i7")
|
robolectricSdk("org.robolectric:android-all-instrumented:11-robolectric-6757853-i7")
|
||||||
robolectricSdk("org.robolectric:android-all-instrumented:14-robolectric-10818077-i7")
|
robolectricSdk("org.robolectric:android-all-instrumented:14-robolectric-10818077-i7")
|
||||||
|
robolectricSdk("org.robolectric:android-all-instrumented:16-robolectric-13921718-i7")
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.tv.material)
|
implementation(libs.androidx.tv.material)
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,14 @@ import androidx.tv.material3.Text
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
|
|
||||||
/** Screens and the shell interpret the same Android directional and activation keys. */
|
/** Native Back belongs to the Activity dispatcher; Escape remains the local Back shortcut. */
|
||||||
fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) {
|
fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) {
|
||||||
Key.DirectionUp -> ShellKey.Up
|
Key.DirectionUp -> ShellKey.Up
|
||||||
Key.DirectionDown -> ShellKey.Down
|
Key.DirectionDown -> ShellKey.Down
|
||||||
Key.DirectionLeft -> ShellKey.Left
|
Key.DirectionLeft -> ShellKey.Left
|
||||||
Key.DirectionRight -> ShellKey.Right
|
Key.DirectionRight -> ShellKey.Right
|
||||||
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok
|
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok
|
||||||
Key.Back, Key.Escape -> ShellKey.Back
|
Key.Escape -> ShellKey.Back
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ class DiscoverKeysTest {
|
||||||
assertEquals(path.getInt("presses"), keys.length())
|
assertEquals(path.getInt("presses"), keys.length())
|
||||||
launch()
|
launch()
|
||||||
repeat(keys.length()) { index -> press(when (keys.getString(index)) {
|
repeat(keys.length()) { index -> press(when (keys.getString(index)) {
|
||||||
"OK" -> Key.DirectionCenter; "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown; "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight; else -> Key.Back
|
"OK" -> Key.DirectionCenter; "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown; "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight; else -> Key.Escape
|
||||||
}) }
|
}) }
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +144,7 @@ class DiscoverKeysTest {
|
||||||
@Test fun closingAnUncertainReceiptNeverTurnsReopeningIntoAnotherPost() {
|
@Test fun closingAnUncertainReceiptNeverTurnsReopeningIntoAnotherPost() {
|
||||||
operations.next = RequestPhase.NotConfirmed
|
operations.next = RequestPhase.NotConfirmed
|
||||||
replay("Request a pick for yourself")
|
replay("Request a pick for yourself")
|
||||||
press(Key.Back, Key.DirectionCenter)
|
press(Key.Escape, Key.DirectionCenter)
|
||||||
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||||
operations.next = RequestPhase.Receipt
|
operations.next = RequestPhase.Receipt
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
|
|
@ -173,7 +173,7 @@ class DiscoverKeysTest {
|
||||||
press(Key.DirectionDown, Key.DirectionCenter)
|
press(Key.DirectionDown, Key.DirectionCenter)
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
assertEquals("shared", operations.sent.single().requester)
|
assertEquals("shared", operations.sent.single().requester)
|
||||||
press(Key.Back, Key.DirectionCenter)
|
press(Key.Escape, Key.DirectionCenter)
|
||||||
press(Key.DirectionDown, Key.DirectionCenter)
|
press(Key.DirectionDown, Key.DirectionCenter)
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
assertEquals(1, operations.sent.size)
|
assertEquals(1, operations.sent.size)
|
||||||
|
|
@ -260,7 +260,7 @@ class DiscoverKeysTest {
|
||||||
val origin = shell.view
|
val origin = shell.view
|
||||||
state.openCollection(state.lanes.first { it.id == "pk-movie" })
|
state.openCollection(state.lanes.first { it.id == "pk-movie" })
|
||||||
launch()
|
launch()
|
||||||
press(Key.DirectionDown, Key.Back)
|
press(Key.DirectionDown, Key.Escape)
|
||||||
assertNull(state.collection)
|
assertNull(state.collection)
|
||||||
assertSame(origin, shell.view)
|
assertSame(origin, shell.view)
|
||||||
assertEquals(3, shell.view.row)
|
assertEquals(3, shell.view.row)
|
||||||
|
|
@ -289,9 +289,9 @@ class DiscoverKeysTest {
|
||||||
val moved = state.column
|
val moved = state.column
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
assertNotNull("This round trip must open a title, not a service lane", state.title)
|
assertNotNull("This round trip must open a title, not a service lane", state.title)
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
assertEquals(moved, state.column)
|
assertEquals(moved, state.column)
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
press(Key.DirectionLeft, Key.DirectionCenter)
|
press(Key.DirectionLeft, Key.DirectionCenter)
|
||||||
assertEquals("home", shell.route)
|
assertEquals("home", shell.route)
|
||||||
press(Key.DirectionUp, Key.DirectionRight)
|
press(Key.DirectionUp, Key.DirectionRight)
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ class HomeKeysTest {
|
||||||
val key = when (name) {
|
val key = when (name) {
|
||||||
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown
|
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown
|
||||||
"LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight
|
"LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight
|
||||||
"BACK" -> Key.Back; else -> Key.DirectionCenter
|
"BACK" -> Key.Escape; else -> Key.DirectionCenter
|
||||||
}
|
}
|
||||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ class HomeNavigationTest {
|
||||||
mount()
|
mount()
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
rule.runOnIdle { outgoing = null }
|
rule.runOnIdle { outgoing = null }
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
rule.mainClock.autoAdvance = false
|
rule.mainClock.autoAdvance = false
|
||||||
|
|
@ -80,7 +80,7 @@ class HomeNavigationTest {
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
rule.onNodeWithTag("title-page").assertIsDisplayed()
|
rule.onNodeWithTag("title-page").assertIsDisplayed()
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
rule.runOnIdle { outgoing = null }
|
rule.runOnIdle { outgoing = null }
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertEquals(2, shell.view.row)
|
assertEquals(2, shell.view.row)
|
||||||
|
|
@ -95,7 +95,7 @@ class HomeNavigationTest {
|
||||||
val saved = shell.view
|
val saved = shell.view
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
rule.onNodeWithTag("title-page").assertIsDisplayed()
|
rule.onNodeWithTag("title-page").assertIsDisplayed()
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
assertSame(saved, shell.view)
|
assertSame(saved, shell.view)
|
||||||
assertEquals(1, shell.view.row)
|
assertEquals(1, shell.view.row)
|
||||||
walkDown(start = 1)
|
walkDown(start = 1)
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ class HomeRatingsUiTest {
|
||||||
rule.onNodeWithText("7.8").assertDoesNotExist()
|
rule.onNodeWithText("7.8").assertDoesNotExist()
|
||||||
rule.mainClock.advanceTimeBy(450)
|
rule.mainClock.advanceTimeBy(450)
|
||||||
rule.onNodeWithText("7.8").assertExists()
|
rule.onNodeWithText("7.8").assertExists()
|
||||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) }
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Escape) }
|
||||||
rule.runOnIdle { assertTrue(shell.navFocused) }
|
rule.runOnIdle { assertTrue(shell.navFocused) }
|
||||||
rule.mainClock.advanceTimeBy(32)
|
rule.mainClock.advanceTimeBy(32)
|
||||||
rule.onNodeWithText("7.8").assertDoesNotExist()
|
rule.onNodeWithText("7.8").assertDoesNotExist()
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class HomeViewportTest {
|
||||||
capture("viewport-${row.id}")
|
capture("viewport-${row.id}")
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
rule.onNodeWithTag("title-page").assertIsDisplayed()
|
rule.onNodeWithTag("title-page").assertIsDisplayed()
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
assertVisible(shell, rows)
|
assertVisible(shell, rows)
|
||||||
}
|
}
|
||||||
repeat(rows.lastIndex) { press(Key.DirectionUp); assertVisible(shell, rows) }
|
repeat(rows.lastIndex) { press(Key.DirectionUp); assertVisible(shell, rows) }
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ class LibraryKeysTest {
|
||||||
repeat(6) { press(Key.DirectionRight) }
|
repeat(6) { press(Key.DirectionRight) }
|
||||||
assertEquals(5, grid.index)
|
assertEquals(5, grid.index)
|
||||||
assertFalse(grid.inRail)
|
assertFalse(grid.inRail)
|
||||||
press(listOf(Key.DirectionDown, Key.Back))
|
press(listOf(Key.DirectionDown, Key.Escape))
|
||||||
assertTrue(shell.navFocused)
|
assertTrue(shell.navFocused)
|
||||||
assertEquals(11, grid.index)
|
assertEquals(11, grid.index)
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ class MotionSurfaceTest {
|
||||||
assertEquals("header", state.row)
|
assertEquals("header", state.row)
|
||||||
assertEquals(0, state.column)
|
assertEquals(0, state.column)
|
||||||
}
|
}
|
||||||
rule.onRoot().performKeyInput { pressKey(Key.Back) }
|
rule.onRoot().performKeyInput { pressKey(Key.Escape) }
|
||||||
rule.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) }
|
rule.runOnIdle { assertEquals(listOf(TitleAction.Pop), actions); assertFalse(shell.pickerVisible) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ class PickerNavigationTest {
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
assertEquals("settings", state.route)
|
assertEquals("settings", state.route)
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
assertTrue(state.pickerVisible)
|
assertTrue(state.pickerVisible)
|
||||||
assertTrue(state.gearFocused)
|
assertTrue(state.gearFocused)
|
||||||
press(Key.DirectionUp)
|
press(Key.DirectionUp)
|
||||||
|
|
|
||||||
|
|
@ -76,11 +76,11 @@ class RequestsKeysTest {
|
||||||
send(Key.DirectionCenter)
|
send(Key.DirectionCenter)
|
||||||
assertFalse(state.requestsVisible)
|
assertFalse(state.requestsVisible)
|
||||||
assertTrue(state.route.startsWith("title/"))
|
assertTrue(state.route.startsWith("title/"))
|
||||||
send(Key.Back)
|
send(Key.Escape)
|
||||||
rule.onNodeWithTag("coming-row-x6").assertExists()
|
rule.onNodeWithTag("coming-row-x6").assertExists()
|
||||||
assertEquals("x6", state.requests.focusedId)
|
assertEquals("x6", state.requests.focusedId)
|
||||||
assertTrue(state.requestsVisible)
|
assertTrue(state.requestsVisible)
|
||||||
send(Key.Back)
|
send(Key.Escape)
|
||||||
assertFalse(state.requestsVisible)
|
assertFalse(state.requestsVisible)
|
||||||
assertEquals("settings", state.route)
|
assertEquals("settings", state.route)
|
||||||
assertEquals(0, state.settingsRow)
|
assertEquals(0, state.settingsRow)
|
||||||
|
|
@ -126,7 +126,7 @@ class RequestsKeysTest {
|
||||||
mount(state)
|
mount(state)
|
||||||
send(Key.DirectionCenter, Key.DirectionUp, Key.DirectionCenter)
|
send(Key.DirectionCenter, Key.DirectionUp, Key.DirectionCenter)
|
||||||
assertEquals("home", state.route)
|
assertEquals("home", state.route)
|
||||||
send(Key.Back)
|
send(Key.Escape)
|
||||||
assertFalse("Root Back must focus navigation, not reopen the abandoned sheet", state.requestsVisible)
|
assertFalse("Root Back must focus navigation, not reopen the abandoned sheet", state.requestsVisible)
|
||||||
assertTrue(state.navFocused)
|
assertTrue(state.navFocused)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ class ScreenEntryNavigationTest {
|
||||||
val view = shell.view
|
val view = shell.view
|
||||||
val before = position(route)
|
val before = position(route)
|
||||||
rule.runOnIdle { shell.enterDestination("title/child"); childVisible = true }
|
rule.runOnIdle { shell.enterDestination("title/child"); childVisible = true }
|
||||||
press(Key.Back)
|
press(Key.Escape)
|
||||||
rule.runOnIdle { outgoingVisible = false }
|
rule.runOnIdle { outgoingVisible = false }
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertSame(view, shell.view)
|
assertSame(view, shell.view)
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ class ShellKeysTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun settingsBackBackInTenPresses() {
|
@Test fun settingsBackBackInTenPresses() {
|
||||||
replay(settings + listOf(Key.Back, Key.Back), 10)
|
replay(settings + listOf(Key.Escape, Key.Escape), 10)
|
||||||
assertEquals("home", state.route)
|
assertEquals("home", state.route)
|
||||||
assertFalse(state.pickerVisible)
|
assertFalse(state.pickerVisible)
|
||||||
assertTrue(state.navFocused)
|
assertTrue(state.navFocused)
|
||||||
|
|
@ -64,7 +64,7 @@ class ShellKeysTest {
|
||||||
replay(listOf(Key.DirectionCenter), 1)
|
replay(listOf(Key.DirectionCenter), 1)
|
||||||
val view = state.view
|
val view = state.view
|
||||||
rule.runOnIdle { view.row = 4; view.cards["row-4"] = 2 }
|
rule.runOnIdle { view.row = 4; view.cards["row-4"] = 2 }
|
||||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back); pressKey(Key.DirectionDown) }
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Escape); pressKey(Key.DirectionDown) }
|
||||||
assertSame(view, state.view)
|
assertSame(view, state.view)
|
||||||
assertEquals(4, state.view.row)
|
assertEquals(4, state.view.row)
|
||||||
assertEquals(2, state.view.cards["row-4"])
|
assertEquals(2, state.view.cards["row-4"])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue