Keep the person picker closed after switching users
All checks were successful
review/code-gpt Picker double-OK fix verified on the real box (B9): three single-OK person switches justin/mindel/shared, no crash, logcat clean; 45 targeted tests, final-head apk build
All checks were successful
review/code-gpt Picker double-OK fix verified on the real box (B9): three single-OK person switches justin/mindel/shared, no crash, logcat clean; 45 targeted tests, final-head apk build
This commit is contained in:
parent
e8d0c52088
commit
1c21f50acb
3 changed files with 168 additions and 3 deletions
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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 →
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue