Read live shell state inside cached navigation entries

This commit is contained in:
justinzeus 2026-11-06 11:52:48 +01:00
parent d401ebc355
commit afff383b75
4 changed files with 140 additions and 2 deletions

View file

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

View file

@ -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<ComponentActivity>()
@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<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
val api = mockk<ApiClient>()
val views = mockk<UserViewsApi>()
val items = mockk<ItemsApi>()
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)
}
}

View file

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