Merge pull request 'Keep the person picker closed after switching users' (#42) from fm/tv-picker-ok into main

This commit is contained in:
justinzeus 2026-11-06 15:01:46 +01:00
commit 93a41ea2d3
3 changed files with 168 additions and 3 deletions

View file

@ -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<ComponentActivity>()
private lateinit var previousUrl: String
private var previousFixtures = false
private var previousScreens = true
private val models = mutableListOf<WolfskinShellViewModel>()
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<Key> = 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<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
val setup = mockk<SetupNavigationManager>(relaxed = true)
every { setup.navigateTo(any()) } answers { current = (firstArg() as SetupDestination.AppContent).current }
val libraries = mockk<LibraryReadFacade>(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<ShellAction.Person>()
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<Key>) {
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))
}

View file

@ -63,6 +63,7 @@ import org.justinvisser.wolfskin.design.DiscoverScreen
class WolfskinSession @Inject constructor() {
var state by mutableStateOf<ShellState?>(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() }

View file

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