diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt new file mode 100644 index 00000000..1af82b5c --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt @@ -0,0 +1,111 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +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.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.ShellKey +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class AnimeRootRemovalTest { + private var previousFixtures = false + private var previousUrl = "" + + @Before fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + ConductarrPreference.initialize(ApplicationProvider.getApplicationContext()) + previousFixtures = ConductarrPreference.fixtures + previousUrl = ConductarrPreference.baseUrl + ConductarrPreference.updateFixtures(false) + } + + @After fun tearDown() { + ConductarrPreference.updateBaseUrl(previousUrl) + ConductarrPreference.updateFixtures(previousFixtures) + Dispatchers.resetMain() + } + + @Test fun removedAnimeRootReturnsHomeWithoutDiscardingPushedPagesOrAvatarFocus() = runBlocking { + val title = Destination.Wolfskin("title/tv%3A123") + val person = Destination.Wolfskin("person/456") + val scenarios = listOf(emptyList() to false, emptyList() to true, + listOf(title) to false, listOf(title, person) to false) + for ((pushed, avatarFocused) in scenarios) FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val context = ApplicationProvider.getApplicationContext() + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns null + var hasTitles = true + val libraries = mockk(relaxed = true) + coEvery { libraries.readDestinations(user.id) } returns emptyMap() + coEvery { libraries.hasAnimeTitles(user.id) } coAnswers { hasTitles } + val navigation = NavigationManager() + val session = WolfskinSession() + val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + try { + model.load(server, user) + requireNotNull(model.onForeground(server, user)).join() + val shell = requireNotNull(session.state) + assertTrue(shell.person.hasAnime) + shell.key(ShellKey.Ok) + shell.focusNav(shell.tabs.indexOf("anime")) + shell.key(ShellKey.Ok) + assertEquals(listOf(Destination.Wolfskin("anime")), navigation.backStack.toList()) + for (page in pushed) { + navigation.navigateTo(page) + shell.enterDestination(page.route) + } + if (avatarFocused) shell.focusNav(shell.tabs.size) + + hasTitles = false + requireNotNull(model.onForeground(server, user)).join() + assertFalse("The foreground read removed the last accessible Anime title", "anime" in shell.tabs) + assertEquals("Home must replace the missing root, not the current pushed page", + listOf(Destination.Home()) + pushed, navigation.backStack.toList()) + assertEquals(pushed.lastOrNull()?.route ?: "home", shell.route) + assertEquals("The selected root must be Home even below a pushed page", 0, shell.currentTab) + if (avatarFocused) assertEquals(shell.tabs.size, shell.navIndex) + + shell.key(ShellKey.Back) + if (pushed.isEmpty()) { + assertTrue("Back from the replacement Home must enter its nav ladder", shell.navFocused) + assertEquals("home", shell.route) + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + if (!avatarFocused) assertEquals(0, shell.navIndex) + } else { + assertEquals("Back pops only the current page and retains the replacement Home root", + listOf(Destination.Home()) + pushed.dropLast(1), navigation.backStack.toList()) + } + } finally { model.viewModelScope.cancel() } + } + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index b654df48..a4859d21 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -181,7 +181,14 @@ class WolfskinShellViewModel @Inject constructor( private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) { when (action) { - is ShellAction.Destination -> if (action.route == "settings") navigation.navigateTo(Destination.Wolfskin("settings")) else navigation.replace(if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)) + is ShellAction.Destination -> { + val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route) + when { + action.route == "settings" -> navigation.navigateTo(target) + action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target + else -> navigation.replace(target) + } + } ShellAction.Pop -> navigation.goBack() is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) is ShellAction.Person -> selectPerson(action.person, server, user) diff --git a/docker/README.md b/docker/README.md index 1996dd3d..d7463830 100644 --- a/docker/README.md +++ b/docker/README.md @@ -485,8 +485,9 @@ Cached `NavDisplay` entries read the current shell and Discover composition loca not the temporary launch shell captured when the entry was first created. Otherwise the screen can retain skeleton people, disconnected tab actions and an open picker even after the real session is ready. -Only the current navigation entry may set the shared route or handle Back; -outgoing entries can remain composed during a transition. +Only the current navigation entry may set the shared route or enable the outer +host's Back handler; outgoing entries can remain composed during a transition. +Nested title handlers retain their existing lifecycle-based behaviour. Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's authenticated fetcher. The app's network interceptor removes Authorization from @@ -500,7 +501,10 @@ in an accessible Anime library for the signed-in user. This runs after the live picker is published; failures retain that user's last confirmed availability. Empty Anime Home shelves remain omitted. When availability changes, focus follows the avatar or a surviving destination; -removing Anime never leaves an out-of-range nav selection. +removing Anime never leaves an out-of-range nav selection. If Anime was the root, +both the shell and navigation stack replace it with Home while retaining pushed +title/person pages. `AnimeRootRemovalTest` covers active-root Back, avatar focus, +and the real navigation stack with one or two pushed pages. | Screen seam | Production supplier | | --- | --- | diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index e54c7ed8..c770a3ab 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -11,7 +11,7 @@ data class ShellPerson(val key: String, val name: String, val initial: String, v /** The shell owns keys; the integration owns navigation and authenticated user changes. */ sealed interface ShellAction { - data class Destination(val route: String) : ShellAction + data class Destination(val route: String, val preservePushed: Boolean = false) : ShellAction data class Person(val person: ShellPerson) : ShellAction data class HandOff(val group: String) : ShellAction data object Pop : ShellAction @@ -59,8 +59,14 @@ class ShellState( people = value if (selected != null) person = selected pickerIndex = people.indexOf(person).coerceAtLeast(0) + val removedRoot = rootRoute !in tabs + if (removedRoot) { + if (route == rootRoute) route = "home" + rootRoute = "home" + } preparePersonMemory() navIndex = if (avatarFocused) tabs.size else tabs.indexOf(focusedTab).takeIf { it >= 0 } ?: currentTab + if (removedRoot) onAction(ShellAction.Destination("home", preservePushed = true)) } var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first()) private set