Read live shell state inside cached navigation entries
This commit is contained in:
parent
d401ebc355
commit
afff383b75
4 changed files with 140 additions and 2 deletions
|
|
@ -27,6 +27,7 @@ internal class FakeConductarr : Closeable {
|
||||||
@Volatile var seasonTwoScore = 8.1
|
@Volatile var seasonTwoScore = 8.1
|
||||||
@Volatile var localArt = false
|
@Volatile var localArt = false
|
||||||
@Volatile var beforeResponse: ((String) -> Unit)? = null
|
@Volatile var beforeResponse: ((String) -> Unit)? = null
|
||||||
|
@Volatile var audiencesBody: String? = null
|
||||||
private val imageBytes = java.util.Base64.getDecoder().decode(
|
private val imageBytes = java.util.Base64.getDecoder().decode(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
|
||||||
|
|
||||||
|
|
@ -66,6 +67,7 @@ internal class FakeConductarr : Closeable {
|
||||||
}
|
}
|
||||||
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
|
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
|
||||||
if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus)
|
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 (notModified && request.headers["If-None-Match"] != null) return response("", 304)
|
||||||
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
|
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
|
||||||
var body = wire.getValue(key).jsonObject
|
var body = wire.getValue(key).jsonObject
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -230,6 +230,8 @@ fun WolfskinApp(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WolfskinShellViewModel = hiltViewModel(),
|
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()
|
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
||||||
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
|
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()),
|
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
||||||
entryProvider = { destination ->
|
entryProvider = { destination ->
|
||||||
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
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) {
|
val route = when (destination) {
|
||||||
is Destination.Home -> "home"
|
is Destination.Home -> "home"
|
||||||
is Destination.Wolfskin -> destination.route
|
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())
|
is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString())
|
||||||
else -> "pushed"
|
else -> "pushed"
|
||||||
}
|
}
|
||||||
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) }
|
LaunchedEffect(destination, state) { if (!destination.fullScreen) state.enterDestination(route) }
|
||||||
if (destination.fullScreen) {
|
if (destination.fullScreen) {
|
||||||
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -297,7 +302,7 @@ fun WolfskinApp(
|
||||||
when {
|
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 || 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)
|
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/") ->
|
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)
|
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)
|
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
|
|
|
||||||
|
|
@ -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
|
items whose detail omits ProviderIds or People get an explicit Jellyfin field read
|
||||||
before catalogue enrichment, without changing their playback identity. Scrolled
|
before catalogue enrichment, without changing their playback identity. Scrolled
|
||||||
navigation draws an opaque band; the unscrolled hero treatment is unchanged.
|
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
|
Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's
|
||||||
authenticated fetcher. The app's network interceptor removes Authorization from
|
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
|
`DiscoverTitleTimingTest` delays the cast reply, exercises real remote Down/Back
|
||||||
and writes native cast/related captures to `walk-fixes/`.
|
and writes native cast/related captures to `walk-fixes/`.
|
||||||
`NavBandOcclusionTest` checks pixels across the entire scrolled navigation band.
|
`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;
|
Not done here: deploying the dedicated hostname or widening server authority;
|
||||||
hardware installation/playback verification; releases, signing or version changes.
|
hardware installation/playback verification; releases, signing or version changes.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue