Round 2 candidate 2: merge origin/fm/tv-back-title

This commit is contained in:
root 2026-11-07 22:33:36 +01:00
commit d739a201ee
27 changed files with 196 additions and 77 deletions

View file

@ -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")

View file

@ -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) }
} }

View file

@ -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)

View file

@ -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)

View file

@ -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)
} }

View file

@ -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()
}
}
} }

View file

@ -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)
} }

View file

@ -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()

View file

@ -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())

View file

@ -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 {

View file

@ -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)

View file

@ -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"
}

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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
} }

View file

@ -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)

View file

@ -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) }
} }

View file

@ -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)

View file

@ -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()

View file

@ -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) }

View file

@ -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)

View file

@ -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) }
} }

View file

@ -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)

View file

@ -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)
} }

View file

@ -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)

View file

@ -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"])