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
|
|
@ -43,7 +43,7 @@ class DiscoverSkeletonKeysTest {
|
|||
}
|
||||
press(Key.DirectionDown)
|
||||
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) }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class DiscoverTitleTimingTest {
|
|||
assertEquals("related", model.title!!.row)
|
||||
rule.onNodeWithText("More like this").assertIsDisplayed()
|
||||
rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath)
|
||||
rule.onRoot().performKeyInput { pressKey(Key.Back) }
|
||||
rule.onRoot().performKeyInput { pressKey(Key.Escape) }
|
||||
rule.waitForIdle()
|
||||
assertNull(discover.title)
|
||||
assertEquals("discover", shell.route)
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ class HomeSeeAllNavigationTest {
|
|||
try {
|
||||
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||
exerciseGrid(grid)
|
||||
press(Key.Back)
|
||||
press(Key.Escape)
|
||||
rule.waitForIdle()
|
||||
assertTrue(navigation.backStack.last() is Destination.Home)
|
||||
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.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
|
@ -47,6 +48,7 @@ import java.util.UUID
|
|||
class TitleAsyncFocusTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private lateinit var viewModel: WolfskinTitleViewModel
|
||||
private val navigation = NavigationManager()
|
||||
|
||||
@After fun dispose() {
|
||||
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
|
||||
|
|
@ -58,9 +60,10 @@ class TitleAsyncFocusTest {
|
|||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.readSaved(id, any()) } returns null
|
||||
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 route = titleRoute(id.toString())
|
||||
navigation.navigateTo(Destination.Wolfskin(route))
|
||||
val shell = ShellState(listOf(person), person.key).apply {
|
||||
pickerVisible = false
|
||||
pickerLaunch = false
|
||||
|
|
@ -100,9 +103,10 @@ class TitleAsyncFocusTest {
|
|||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.readPersonSaved(id) } returns null
|
||||
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 route = personRoute(id.toString())
|
||||
navigation.navigateTo(Destination.Wolfskin(route))
|
||||
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
||||
pickerVisible = false
|
||||
pickerLaunch = false
|
||||
|
|
@ -146,9 +150,10 @@ class TitleAsyncFocusTest {
|
|||
reads.incrementAndGet()
|
||||
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 route = titleRoute(id.toString())
|
||||
navigation.navigateTo(Destination.Wolfskin(route))
|
||||
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
||||
pickerVisible = false; pickerLaunch = false; enterDestination(route)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
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.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
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.Shadows
|
||||
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)
|
||||
@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 {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private lateinit var viewModel: WolfskinTitleViewModel
|
||||
|
|
@ -45,13 +53,50 @@ class TitleEntryBackTest {
|
|||
|
||||
@Test fun outgoingLoadedTitleCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = false)
|
||||
@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 navigation = NavigationManager()
|
||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||
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 }
|
||||
val id = UUID.fromString("00000000-0000-0000-0000-000000000031")
|
||||
val route = if (personPage) personRoute(id.toString()) else titleRoute(id.toString())
|
||||
|
|
@ -74,16 +119,18 @@ class TitleEntryBackTest {
|
|||
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
rememberWolfskinBackHandler(shell, navigation)
|
||||
val current = navigation.backStack.last()
|
||||
LaunchedEffect(current) { if (current is Destination.Home) shell.enterDestination("home") }
|
||||
Box {
|
||||
ScreenEntry(shell, "home", current is Destination.Home) { HomeScreen(shell, home) {} }
|
||||
// Retain the real outgoing destination just as NavDisplay does during its exit animation.
|
||||
ScreenEntry(shell, route, current == destination) {
|
||||
TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = false)
|
||||
val onBack = rememberWolfskinBackHandler(shell, navigation)
|
||||
NavDisplay(backStack = navigation.backStack, onBack = onBack, entryProvider = { entry ->
|
||||
NavEntry(entry) {
|
||||
val active = navigation.backStack.lastOrNull() == entry
|
||||
val entryRoute = if (entry is Destination.Home) "home" else route
|
||||
LaunchedEffect(entry, active) { if (active) shell.enterDestination(entryRoute) }
|
||||
ScreenEntry(shell, entryRoute, active) {
|
||||
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) {
|
||||
|
|
@ -91,17 +138,37 @@ class TitleEntryBackTest {
|
|||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||
(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()
|
||||
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||
assertFalse(shell.navFocused)
|
||||
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
|
||||
dispatchBack(predictive)
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
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)
|
||||
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
|
||||
dispatchBack(predictive)
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
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)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(1, state.sheetIndex)
|
||||
press(Key.Back)
|
||||
press(Key.Escape)
|
||||
assertNull(state.sheet)
|
||||
assertEquals("Version", state.options[state.column].label)
|
||||
assertFalse(shell.navFocused)
|
||||
|
|
@ -161,7 +161,7 @@ class TitleKeysTest {
|
|||
assertEquals(2, state.subtitleIndex)
|
||||
press(Key.DirectionUp); press(Key.DirectionCenter)
|
||||
assertEquals(-1, state.subtitleIndex)
|
||||
press(Key.Back)
|
||||
press(Key.Escape)
|
||||
assertNull(state.sheet)
|
||||
assertEquals("tracks", state.options[state.column].id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class TitleRound2Test {
|
|||
render()
|
||||
menu()
|
||||
val opener = state.column
|
||||
press(Key.Back)
|
||||
press(Key.Escape)
|
||||
assertNull(state.sheet)
|
||||
assertEquals(opener, state.column)
|
||||
assertFalse(shell.navFocused)
|
||||
|
|
@ -64,7 +64,7 @@ class TitleRound2Test {
|
|||
press(Key.DirectionCenter)
|
||||
assertEquals(TitleSheet.MediaInfo, state.sheet)
|
||||
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
||||
press(Key.Back)
|
||||
press(Key.Escape)
|
||||
assertEquals(TitleSheet.Menu, state.sheet)
|
||||
press(Key.DirectionDown)
|
||||
press(Key.DirectionCenter)
|
||||
|
|
@ -115,7 +115,7 @@ class TitleRound2Test {
|
|||
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
|
||||
menu()
|
||||
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
||||
press(Key.Back)
|
||||
press(Key.Escape)
|
||||
press(Key.DirectionDown)
|
||||
assertEquals("seasons", state.row)
|
||||
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
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.ui.nav.Destination
|
||||
import io.mockk.coEvery
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import io.mockk.mockk
|
||||
import java.util.UUID
|
||||
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. */
|
||||
@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 WolfskinRootExitTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
|
@ -73,11 +75,18 @@ class WolfskinRootExitTest {
|
|||
@Test fun hardwareBackUsesTheSameContentNavExitLadder() {
|
||||
mount()
|
||||
val shell = session.state!!
|
||||
rule.runOnIdle { shell.pickerVisible = false; shell.pickerLaunch = false }
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) }
|
||||
rule.runOnIdle { shell.pickerVisible = false; shell.pickerLaunch = false; shell.navFocused = false; shell.view.row = 1 }
|
||||
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)
|
||||
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)
|
||||
assertTrue(activity.isFinishing)
|
||||
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue