From 1c21f50acb8b75a3c792627d02962a7bd7d8115e Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 14:48:36 +0100 Subject: [PATCH] Keep the person picker closed after switching users --- .../wolfskin/tv/PersonPickerSessionTest.kt | 157 ++++++++++++++++++ .../justinvisser/wolfskin/tv/WolfskinApp.kt | 6 +- docker/README.md | 8 + 3 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt new file mode 100644 index 00000000..8e13dca4 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt @@ -0,0 +1,157 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.runtime.* +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.CurrentUser +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.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import io.mockk.* +import java.util.UUID +import kotlinx.coroutines.Job +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 + +/** A selected identity gets a new app ViewModel without opening a second launch picker. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class PersonPickerSessionTest { + @get:Rule val rule = createAndroidComposeRule() + private lateinit var previousUrl: String + private var previousFixtures = false + private var previousScreens = true + private val models = mutableListOf() + private lateinit var session: WolfskinSession + + @Before fun capturePreferences() { + ConductarrPreference.initialize(rule.activity) + WolfskinScreenPreference.initialize(rule.activity) + previousUrl = ConductarrPreference.baseUrl + previousFixtures = ConductarrPreference.fixtures + previousScreens = WolfskinScreenPreference.enabled + ConductarrPreference.updateFixtures(false) + WolfskinScreenPreference.enabled = true + } + + @After fun restorePreferences() { + models.forEach { it.viewModelScope.cancel() } + ConductarrPreference.updateBaseUrl(previousUrl) + ConductarrPreference.updateFixtures(previousFixtures) + WolfskinScreenPreference.enabled = previousScreens + } + + private fun select(initial: Int, selected: Int, fromAvatar: Boolean = false, fast: Boolean = false, moves: List = emptyList()) { + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val users = listOf("Justin" to JUSTIN_TOKEN, "Mindel" to MINDEL_TOKEN, "Shared" to SHARED_TOKEN).map { (name, token) -> + JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = name, accessToken = token) + } + var current by mutableStateOf(CurrentUser(server, users[initial])) + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) + coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) } + val setup = mockk(relaxed = true) + every { setup.navigateTo(any()) } answers { current = (firstArg() as SetupDestination.AppContent).current } + val libraries = mockk(relaxed = true) + coEvery { libraries.readDestinations(any()) } returns emptyMap() + coEvery { libraries.hasAnimeTitles(any()) } returns false + session = WolfskinSession() + try { + rule.setContent { + key(current.user.id) { + val navigation = remember { NavigationManager() } + val model = remember { + WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) } + } + DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } } + WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), + viewModel = model, homeContent = { _, _, user, modifier -> + androidx.tv.material3.Text("Home for ${user.name}", modifier) + }) + } + } + awaitLoaded(users[initial]) + if (fromAvatar) { + press(listOf(Key.DirectionCenter)) + press(listOf(Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter)) + } + val actions = mutableListOf() + rule.runOnIdle { + val dispatch = session.onAction + session.onAction = { action -> + if (action is ShellAction.Person) actions.add(action) + dispatch(action) + } + } + if (fast) press(moves + Key.DirectionCenter) else { + press(moves) + press(listOf(Key.DirectionCenter)) + } + awaitLoaded(users[selected]) + rule.runOnIdle { + assertFalse("The replacement app must not reopen the picker after one OK", session.state!!.pickerVisible) + assertEquals("home", session.state!!.route) + assertEquals(users[selected].id.toString(), session.state!!.person.jellyfinUserId) + assertEquals(listOf(users[selected].id.toString()), actions.map { it.person.jellyfinUserId }) + assertEquals(if (initial == selected) 1 else 2, models.size) + } + rule.onNodeWithText("Home for ${users[selected].name}").assertIsDisplayed() + } finally { models.forEach { it.viewModelScope.cancel() } } + } + } + } + + private fun awaitLoaded(user: JellyfinUser) { + try { + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + models.lastOrNull()?.signedInPerson == user.name!!.lowercase() && + models.last().conductarr.identity.value?.signedIn == true && + models.last().conductarr.identity.value?.person?.endsWith(":${user.id}") == true && + session.state?.people?.size == 3 && + models.last().viewModelScope.coroutineContext[Job]!!.children.none { it.isActive } + } + } catch (timeout: ComposeTimeoutException) { + throw AssertionError("Expected ${user.name}; models=${models.size}, signedIn=${models.last().signedInPerson}, " + + "identity=${models.last().conductarr.identity.value?.person}, picker=${session.state?.pickerVisible}, " + + "person=${session.state?.person}, people=${session.state?.people?.size}", timeout) + } + rule.waitForIdle() + } + + private fun press(keys: List) { + rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it) } } + } + + @Test fun launchRightThenOneOkKeepsPickerClosed() = select(0, 1, moves = listOf(Key.DirectionRight)) + @Test fun launchLeftThenOneOkKeepsPickerClosed() = select(1, 0, moves = listOf(Key.DirectionLeft)) + @Test fun avatarRightThenOneOkKeepsPickerClosed() = select(0, 1, fromAvatar = true, moves = listOf(Key.DirectionRight)) + @Test fun avatarLeftThenOneOkKeepsPickerClosed() = select(1, 0, fromAvatar = true, moves = listOf(Key.DirectionLeft)) + @Test fun initialPersonNeedsOnlyOneOk() = select(0, 0) + @Test fun gearAndBackThenOneOkKeepsPickerClosed() = select(0, 2, moves = listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionUp)) + @Test fun fastLeftLeftOkKeepsPickerClosed() = select(2, 0, fast = true, moves = listOf(Key.DirectionLeft, Key.DirectionLeft)) +} 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 abc0089c..aed8ec7b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -63,6 +63,7 @@ import org.justinvisser.wolfskin.design.DiscoverScreen class WolfskinSession @Inject constructor() { var state by mutableStateOf(null) var serverId: String? = null + internal var fixtureMode: Boolean? = null var onAction: (ShellAction) -> Unit = {} } @@ -90,7 +91,6 @@ class WolfskinShellViewModel @Inject constructor( private var identityJob: kotlinx.coroutines.Job? = null private var personSwitchJob: kotlinx.coroutines.Job? = null private val personSwitchMutex = Mutex() - private var fixtureMode: Boolean? = null suspend fun load(server: JellyfinServer, user: JellyfinUser) { withContext(Dispatchers.IO) { @@ -103,9 +103,9 @@ class WolfskinShellViewModel @Inject constructor( else withContext(Dispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) } signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase() session.onAction = { action -> handleAction(action, server, user) } - if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) { + if (session.state == null || session.serverId != server.id.toString() || session.fixtureMode != ConductarrPreference.fixtures) { session.serverId = server.id.toString() - fixtureMode = ConductarrPreference.fixtures + session.fixtureMode = ConductarrPreference.fixtures val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) } session.state = ShellState(people, signedInPerson) { session.onAction(it) } val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } diff --git a/docker/README.md b/docker/README.md index fe872463..a40c5a4c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -112,6 +112,14 @@ switch. Cleanup outlives the replaced screen. Retry and session replacement use the same disposal owner. Superseded sign-ins are cancelled and serialized so a late reply cannot navigate back to an earlier person. +The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its +server and shell state, not to the user-scoped ViewModel. Switching people replaces +the upstream app entry and ViewModel but retains the same-server, same-mode shell: +the picker stays closed on Home after the first OK. A cold start still opens it, +and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real +shell and user-entry replacement for launch/avatar Left and Right, the initial +person, gear and back, and fast Left-Left-OK; each selection emits one person action. + Preference and people-fixture reads, Conductarr client construction (including Home/title retries), and private-cache directory initialization run on IO. `PersonSwitchThreadingTest` replays the real picker across Justin → Mindel →