diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt index c751b56e..04a8ac97 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -27,6 +27,7 @@ internal class FakeConductarr : Closeable { @Volatile var seasonTwoScore = 8.1 @Volatile var localArt = false @Volatile var beforeResponse: ((String) -> Unit)? = null + @Volatile var audiencesBody: String? = null private val imageBytes = java.util.Base64.getDecoder().decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=") @@ -66,6 +67,7 @@ internal class FakeConductarr : Closeable { } if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus) if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus) + if (key == "audiences") audiencesBody?.let { return response(it) } if (notModified && request.headers["If-None-Match"] != null) return response("", 304) if (key == "requests" && !existingRequests) return response("{\"requests\":[]}") var body = wire.getValue(key).jsonObject diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt new file mode 100644 index 00000000..b0102a5b --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -0,0 +1,122 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.ui.Modifier +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.* +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.successQueryResult +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.cancel +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.UserViewsApi +import org.jellyfin.sdk.model.api.* +import org.junit.Assert.* +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 + +/** Fresh login exercises the actual NavDisplay and renders user-prefixed Jellyfin libraries. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class FirstSignInNavigationTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun firstContinueThenMoviesRendersMindelsGridWithoutReenteringThePicker() { + ConductarrPreference.initialize(rule.activity) + ConductarrPreference.updateFixtures(false) + WolfskinScreenPreference.initialize(rule.activity) + WolfskinScreenPreference.enabled = true + val previousUrl = ConductarrPreference.baseUrl + FakeConductarr().use { fake -> + fake.audiencesBody = """{"audiences":[{"is_anime":false,"libraries":["Mindel Movies"],"media_type":"movie","owner":"mindel","users":["mindel"]},{"is_anime":false,"libraries":["Mindel Shows"],"media_type":"tv","owner":"mindel","users":["mindel"]}]}""" + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "mindel", serverId = server.id, accessToken = MINDEL_TOKEN) + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user)) + val api = mockk() + val views = mockk() + val items = mockk() + every { api.userViewsApi } returns views + every { api.itemsApi } returns items + val movies = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES) + val shows = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Shows", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS) + coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows)) + for ((library, kind, count) in listOf(Triple(movies, BaseItemKind.MOVIE, 6), Triple(shows, BaseItemKind.SERIES, 29))) { + coEvery { items.getItems(userId = user.id, parentId = library.id, recursive = true, + includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), + sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult( + List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) }) + } + val navigation = NavigationManager() + val session = WolfskinSession() + val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), + LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf( + HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume"))) + try { + rule.setContent { + WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), Modifier, model, + homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} }) + } + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + conductarr.identity.value?.signedIn == true && model.libraries.audiences != null + } + rule.onNodeWithText("Mindel").assertIsDisplayed() + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } + rule.onNodeWithText("Desperate Housewives").assertIsDisplayed() + assertFalse("Continue must close the session's real picker, not a discarded launch shell", session.state!!.pickerVisible) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { + pressKey(Key.DirectionUp) + pressKey(Key.DirectionRight) + pressKey(Key.DirectionRight) + pressKey(Key.DirectionCenter) + } + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().isNotEmpty() + } + rule.onNodeWithTag("library-grid").assertIsDisplayed() + rule.onAllNodesWithText("Mindel Movies title 0")[0].assertIsDisplayed() + rule.onNodeWithText("Desperate Housewives").assertDoesNotExist() + assertEquals(6, session.state!!.view.library!!.source.size) + assertFalse(session.state!!.person.hasAnime) + rule.onNodeWithText("Shows").performClick() + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + session.state!!.view.library?.source?.size == 29 + } + rule.onNodeWithTag("library-grid").assertIsDisplayed() + rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed() + rule.onNodeWithText("Home").performClick() + rule.onNodeWithText("Desperate Housewives").assertIsDisplayed() + assertFalse("Returning Home must not reopen the picker", session.state!!.pickerVisible) + } finally { model.viewModelScope.cancel() } + } + } + ConductarrPreference.updateBaseUrl(previousUrl) + } +} 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 a9948c2a..cfc1617b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -230,6 +230,8 @@ fun WolfskinApp( preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinShellViewModel = hiltViewModel(), + homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit = + { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, ) { val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) } @@ -274,13 +276,16 @@ fun WolfskinApp( entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), entryProvider = { destination -> NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") { + // Cached entries must not retain the launch shell after sign-in completes. + val state = LocalShellState.current + val discover = LocalDiscoverState.current val route = when (destination) { 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" } - LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) } + LaunchedEffect(destination, state) { if (!destination.fullScreen) state.enterDestination(route) } if (destination.fullScreen) { DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) } else { @@ -297,7 +302,7 @@ fun WolfskinApp( when { (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) - destination is Destination.Home -> HomeDestination(state, server, user, contentModifier) + destination is Destination.Home -> homeContent(state, server, user, contentModifier) route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") -> WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier) destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) diff --git a/docker/README.md b/docker/README.md index fba9a5d9..5e2b488e 100644 --- a/docker/README.md +++ b/docker/README.md @@ -481,6 +481,10 @@ navigation and related titles, rather than the header-only design preview. Held items whose detail omits ProviderIds or People get an explicit Jellyfin field read before catalogue enrichment, without changing their playback identity. Scrolled navigation draws an opaque band; the unscrolled hero treatment is unchanged. +Cached `NavDisplay` entries read the current shell and Discover composition locals, +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. Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's authenticated fetcher. The app's network interceptor removes Authorization from @@ -519,6 +523,11 @@ covers omitted held-film provider ids with authenticated cast/related enrichment `DiscoverTitleTimingTest` delays the cast reply, exercises real remote Down/Back and writes native cast/related captures to `walk-fixes/`. `NavBandOcclusionTest` checks pixels across the entire scrolled navigation band. +`FirstSignInNavigationTest` starts the full app host with no existing shell and +immediate signed-in replies, then continues as Mindel and renders her six-film +Movies grid and 29-series Shows grid from owner-prefixed Jellyfin views. It also +checks that Continue closes the real session picker and selecting Home does not +reopen it. Not done here: deploying the dedicated hostname or widening server authority; hardware installation/playback verification; releases, signing or version changes.