Fix picker access and finish Wolfskin on root Back

This commit is contained in:
justinzeus 2026-11-07 18:02:17 +01:00
parent febcded034
commit 9cfe9362ee
11 changed files with 481 additions and 31 deletions

View file

@ -0,0 +1,147 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService
import com.github.damontecres.wholphin.ui.main.settings.HomePageSettingsState
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsViewModel
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage
import com.github.damontecres.wholphin.ui.setup.InstallUpdateState
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.util.LoadingState
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Real full-screen pages keep their local Back ladder before task-level exit. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class WolfskinBackStackTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var activity: ComponentActivity
private val navigation = NavigationManager()
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") {
when (it) {
ShellAction.Pop -> navigation.goBack()
ShellAction.Exit -> navigation.requestExit()
else -> Unit
}
}.apply { pickerVisible = false; pickerLaunch = false; navFocused = true }
@Before fun initialize() { activity = rule.activity }
@Test fun singletonUpdatesBackFinishesInsteadOfBecomingADeadEnd() {
navigation.replace(Destination.UpdateApp)
val model = updatesModel()
rule.setContent {
rememberWolfskinBackHandler(shell, navigation)
WolfskinTheme { InstallUpdatePage(preferences, viewModel = model) }
}
rule.onNodeWithText("No release found! Check the update URL.").assertIsDisplayed()
back()
assertTrue(activity.isFinishing)
assertEquals(listOf(Destination.UpdateApp), navigation.backStack.toList())
}
@Test fun pushedUpdatesPopsSettingsThenPickerBeforeRootExit() {
navigation.navigateTo(Destination.Wolfskin("settings"))
navigation.navigateTo(Destination.UpdateApp)
shell.enterDestination("settings")
shell.navFocused = false
val model = updatesModel()
rule.setContent {
rememberWolfskinBackHandler(shell, navigation)
WolfskinTheme {
if (navigation.backStack.last() == Destination.UpdateApp) InstallUpdatePage(preferences, viewModel = model)
else ShellSurface(shell) { SettingsList(shell, it) }
}
}
rule.onNodeWithText("No release found! Check the update URL.").assertIsDisplayed()
back()
assertFalse(activity.isFinishing)
assertEquals(listOf(Destination.Home(), Destination.Wolfskin("settings")), navigation.backStack.toList())
back()
assertFalse(activity.isFinishing)
assertTrue(shell.pickerVisible)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
back()
assertFalse(shell.pickerVisible)
assertTrue(shell.navFocused)
assertFalse(activity.isFinishing)
back()
assertTrue(activity.isFinishing)
}
@Test fun unsavedHomeSettingsPromptsBeforeDiscardCanExitTheTask() {
navigation.replace(Destination.HomeSettings)
val model = mockk<HomeSettingsViewModel>(relaxed = true)
every { model.state } returns MutableStateFlow(HomePageSettingsState.EMPTY.copy(loading = LoadingState.Success))
every { model.serverPluginActive } returns MutableStateFlow(false)
every { model.settingsChanged } returns MutableStateFlow(true)
every { model.navigationManager } returns navigation
every { model.discardChanges() } returns Job().apply { complete() }
val images = mockk<ImageUrlService>(relaxed = true)
val ratings = SeerrRatingsService(mockk(relaxed = true), mockk(relaxed = true))
rule.setContent {
rememberWolfskinBackHandler(shell, navigation)
WolfskinTheme {
CompositionLocalProvider(LocalSeerrRatingsService provides ratings, LocalImageUrlService provides images) {
HomeSettingsPage(preferences, Modifier.fillMaxSize(), viewModel = model)
}
}
}
back()
assertFalse("Back must not discard unsaved settings or finish the task", activity.isFinishing)
assertEquals(listOf(Destination.HomeSettings), navigation.backStack.toList())
val discard = rule.onNodeWithText("Discard").assertIsDisplayed().assertIsEnabled()
discard.performSemanticsAction(SemanticsActions.RequestFocus) { it() }
discard.assertIsFocused().performKeyInput { pressKey(Key.DirectionCenter) }
rule.waitUntil(10_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
activity.isFinishing
}
assertEquals(listOf(Destination.HomeSettings), navigation.backStack.toList())
}
private fun updatesModel(): UpdateViewModel = mockk<UpdateViewModel>(relaxed = true).also { model ->
every { model.state } returns MutableStateFlow(InstallUpdateState(loading = LoadingState.Success))
every { model.navigationManager } returns navigation
every { model.updater.hasPermissions() } returns true
}
private fun back() {
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
rule.mainClock.advanceTimeBy(400)
if (!activity.isFinishing) rule.waitForIdle()
}
}

View file

@ -0,0 +1,184 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
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
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
import com.github.damontecres.wholphin.data.model.JellyfinUser
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 java.util.UUID
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
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")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class WolfskinRootExitTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var activity: ComponentActivity
private lateinit var model: WolfskinShellViewModel
private lateinit var conductarr: ConductarrSession
private val navigation = NavigationManager()
private val session = WolfskinSession()
private var oldFixtures = false
private var oldScreens = true
@Before fun initialize() {
activity = rule.activity
ConductarrPreference.initialize(activity)
WolfskinScreenPreference.initialize(activity)
oldFixtures = ConductarrPreference.fixtures
oldScreens = WolfskinScreenPreference.enabled
ConductarrPreference.updateFixtures(true)
WolfskinScreenPreference.enabled = true
}
@After fun cleanup() {
if (::model.isInitialized) model.viewModelScope.cancel()
if (::conductarr.isInitialized) conductarr.close()
ConductarrPreference.updateFixtures(oldFixtures)
WolfskinScreenPreference.enabled = oldScreens
}
@Test fun homeContentThenNavBackFinishesTheActivity() = verifyRoot("home")
@Test fun discoverContentThenNavBackFinishesTheActivity() = verifyRoot("discover")
@Test fun moviesContentThenNavBackFinishesTheActivity() = verifyRoot("movies")
@Test fun showsContentThenNavBackFinishesTheActivity() = verifyRoot("shows")
@Test fun animeContentThenNavBackFinishesTheActivity() = verifyRoot("anime")
@Test fun hardwareBackUsesTheSameContentNavExitLadder() {
mount()
val shell = session.state!!
rule.runOnIdle { shell.pickerVisible = false; shell.pickerLaunch = false }
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) }
assertTrue(shell.navFocused)
assertFalse(activity.isFinishing)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) }
rule.mainClock.advanceTimeBy(400)
assertTrue(activity.isFinishing)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
}
@Test fun coldPickerBackFinishesWithoutSelectingAnAvatarOrShowingUpstreamUi() {
mount()
assertTrue(session.state!!.pickerLaunch)
back()
assertTrue("Back from the cold picker must finish the activity", activity.isFinishing)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
}
@Test fun reopenedPickerBackDismissesToNavAndTheFollowingBackExits() {
mount()
val shell = session.state!!
rule.runOnIdle { shell.pickerVisible = false; shell.focusNav(shell.tabs.size); shell.key(ShellKey.Ok) }
assertTrue(shell.pickerVisible)
back()
assertFalse(shell.pickerVisible)
assertTrue(shell.navFocused)
assertFalse(activity.isFinishing)
back()
assertTrue(activity.isFinishing)
}
@Test fun comingInSheetDismissesBeforeContentNavAndExit() {
mount()
val shell = session.state!!
rule.runOnIdle {
shell.pickerVisible = false
shell.pickerLaunch = false
shell.navFocused = false
shell.openRequests()
}
assertTrue(shell.requestsVisible)
back()
assertFalse(shell.requestsVisible)
assertFalse(shell.navFocused)
assertFalse(activity.isFinishing)
back()
assertTrue(shell.navFocused)
assertFalse(activity.isFinishing)
back()
assertTrue(activity.isFinishing)
}
private fun verifyRoot(route: String) {
mount()
val shell = session.state!!
val destination = if (route == "home") Destination.Home() else Destination.Wolfskin(route)
rule.runOnIdle {
shell.pickerVisible = false
shell.pickerLaunch = false
shell.enterDestination(route)
navigation.replace(destination)
}
rule.mainClock.advanceTimeBy(1200)
rule.waitForIdle()
rule.waitUntil(10_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
shell.contentFocus?.hasContent == true &&
(route !in LIBRARY_DESTINATIONS || shell.rememberedView(route).library?.status == LibraryStatus.Ready)
}
rule.runOnIdle { shell.navFocused = false }
back()
assertFalse("The first Back must not finish $route content", activity.isFinishing)
assertTrue("The first Back must move $route content to nav", shell.navFocused)
assertEquals(listOf(destination), navigation.backStack.toList())
back()
assertTrue("Back from $route nav must finish the activity", activity.isFinishing)
assertEquals("Exit cannot detour to old Home", listOf(destination), navigation.backStack.toList())
}
private fun mount() {
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null)
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
conductarr = ConductarrSession(MemoryCacheStore())
model = WolfskinShellViewModel(activity, session, repository, navigation, mockk(relaxed = true),
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val home = HomeFixture(activity).snapshot("justin")
rule.setContent {
WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model,
homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} })
}
rule.waitUntil(10_000) {
Snapshot.sendApplyNotifications()
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
session.state?.people?.size == 3 && model.signedInPerson == "justin"
}
rule.waitForIdle()
}
private fun back() {
rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() }
rule.mainClock.advanceTimeBy(400)
if (!activity.isFinishing) rule.waitForIdle()
}
}