Polish picker switching and Home playback handoff

This commit is contained in:
justinzeus 2026-11-08 03:16:09 +01:00
parent 74b737fb52
commit 612ae79f0c
20 changed files with 974 additions and 83 deletions

View file

@ -57,7 +57,7 @@ class ConductarrHomeSurfaceTest {
coEvery { home.readLibrary(user.id) } returns HomeLibrary()
val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session)
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession())
try {
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false }
rule.setContent { WolfskinTheme {

View file

@ -0,0 +1,166 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.*
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.model.*
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.*
import java.io.File
import java.io.IOException
import java.util.UUID
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import org.jellyfin.sdk.model.api.BaseItemDto
import org.junit.*
import org.junit.Assert.*
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Real Home actions keep button geometry, reject duplicate starts and launch only after the black handoff. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HomeHeroStartingTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var model: WolfskinHomeViewModel
private val conductarr = ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())
private val session = WolfskinSession()
private val navigation = NavigationManager()
private val facade = mockk<HomeReadFacade>()
private val release = CompletableDeferred<BaseItemDto?>()
private val visible = mutableStateOf(true)
private var previousFixtures = false
private val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Mindel", accessToken = null)
private val shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false; pickerLaunch = false }
@After fun dispose() {
release.complete(null)
if (::model.isInitialized) model.viewModelScope.cancel()
conductarr.close()
ConductarrPreference.updateFixtures(previousFixtures)
}
private fun mount(reduced: Boolean = false) {
ConductarrPreference.initialize(rule.activity)
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(true)
coEvery { facade.preparePlayback(user.id, any()) } coAnswers { release.await() }
every { facade.audiences } returns null
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), conductarr, session)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced, LocalHomePlaybackStart provides session.playback) { WolfskinTheme {
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) {
if (visible.value && navigation.backStack.last() !is Destination.Playback)
ShellSurface(shell) { modifier -> HomeDestination(shell, server, user, modifier, model) }
PlaybackHandoffVeil(session.playback)
}
} } }
await { model.snapshot.rows.firstOrNull()?.let { !it.skeleton && it.watchReady } == true }
rule.mainClock.advanceTimeBy(1200)
rule.mainClock.autoAdvance = false
}
@Test fun playSaysStartingThenFadesToBlackBeforeThePlayer() = successfulStart(false)
@Test fun reducedMotionCapsBothFadesAndStillStartsOnce() = successfulStart(true)
private fun successfulStart(reduced: Boolean) {
mount(reduced)
val width = rule.onNode(hasText("Resume", substring = true) and hasClickAction()).fetchSemanticsNode().boundsInRoot.width
press()
rule.mainClock.advanceTimeBy(32)
rule.onNodeWithText("Starting…").assertIsDisplayed()
assertEquals(width, rule.onNode(hasText("Starting…") and hasClickAction()).fetchSemanticsNode().boundsInRoot.width, 0f)
press()
assertTrue(navigation.backStack.single() is Destination.Home)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
val samples = mutableListOf<String>()
fun frame(phase: String, index: Int): Int {
val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png")
rule.onRoot().captureRoboImage(file.absolutePath)
val image = BitmapFactory.decodeFile(file.absolutePath)
val pixel = try { image.getPixel(600, 500) } finally { image.recycle() }
samples.add(phase + "," + index * 16 + "," + pixel + "," + navigation.backStack.size)
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
}
repeat(13) { frame("home-start", it); rule.mainClock.advanceTimeByFrame() }
val id = UUID.randomUUID()
release.complete(BaseItemDto(id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE))
await { session.playback.launch != null }
val duration = motionDuration(WolfskinTokens.Motion.base, reduced)
var elapsed = 0
val fading = mutableListOf<Int>()
while (navigation.backStack.last() !is Destination.Playback && elapsed <= duration + 48) {
fading.add(frame("home-black", elapsed / 16))
rule.mainClock.advanceTimeByFrame()
elapsed += 16
}
assertTrue("The player cannot open before the token-bounded fade", elapsed >= duration)
assertTrue("The page must visibly fade rather than wait and cut", fading.first() > fading.last())
assertTrue("The black handoff cannot flicker", fading.zipWithNext().all { (before, after) -> after <= before })
assertTrue("The final painted frame must be near black", fading.last() <= fading.first() / 10)
assertEquals(id, (navigation.backStack.last() as Destination.Playback).itemId)
assertEquals(2, navigation.backStack.size)
assertNull(session.playback.starting)
assertNull(session.playback.launch)
coVerify(exactly = 1) { facade.preparePlayback(user.id, any()) }
File(directory, "samples.csv").writeText("phase,timeMs,pixel,stackSize\n" + samples.joinToString("\n"))
rule.runOnIdle { navigation.goBack() }
rule.mainClock.advanceTimeBy(100)
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
rule.onNodeWithText("Starting…").assertDoesNotExist()
}
@Test fun failedPreparationRestoresResumeAndShowsTheExistingRecoveryToast() {
mount()
press()
rule.mainClock.advanceTimeBy(200)
rule.onNodeWithText("Starting…").assertIsDisplayed()
release.completeExceptionally(IOException("offline"))
await { model.toast != null }
rule.mainClock.advanceTimeBy(200)
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
assertTrue(navigation.backStack.single() is Destination.Home)
}
@Test fun leavingHomeCancelsPreparationAndCannotLaunchALateResult() {
mount()
press()
rule.mainClock.advanceTimeBy(32)
rule.runOnIdle { visible.value = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeBy(32)
release.complete(BaseItemDto(id = UUID.randomUUID(), type = org.jellyfin.sdk.model.api.BaseItemKind.MOVIE))
await { session.playback.starting == null }
rule.mainClock.advanceTimeBy(400)
assertTrue(navigation.backStack.single() is Destination.Home)
assertNull(session.playback.launch)
}
private fun press() = rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) }
private fun await(condition: () -> Boolean) {
rule.waitUntil(5000) {
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
shadowOf(Looper.getMainLooper()).idle()
if (!rule.mainClock.autoAdvance) rule.mainClock.advanceTimeByFrame()
condition()
}
rule.waitForIdle()
}
}

View file

@ -45,7 +45,7 @@ class HomeRecoveryTest {
val facade = mockk<HomeReadFacade>()
coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() }
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
started.await()
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
@ -67,7 +67,7 @@ class HomeRecoveryTest {
coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline")
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
startedA.await()
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
@ -88,7 +88,7 @@ class HomeRecoveryTest {
val cache = mockk<HomeLibraryCache>()
every { cache.read(any()) } returns null
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
model.loadLibrary(server, personA, viewerA)
assertTrue(model.snapshot.jellyfinAvailable)
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
@ -101,7 +101,7 @@ class HomeRecoveryTest {
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name"))
assertTrue(model.snapshot.jellyfinAvailable)
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)

View file

@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest {
}
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
val facade = mockk<LibraryReadFacade>()
rule.setContent {
val destination = navigation.backStack.last()

View file

@ -0,0 +1,233 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.rules.ActivityScenarioRule
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.*
import com.github.damontecres.wholphin.services.*
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.*
import java.io.File
import java.util.UUID
import kotlinx.coroutines.cancel
import org.junit.*
import org.junit.Assert.*
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Each navigation change retires the outgoing shell in the first rendered frame. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class NavigationCutTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var host: PolishAppHost
@After fun dispose() { if (::host.isInitialized) host.close() }
@Test fun titleBackAndTabReplaceTheOutgoingScreenInOneFrame() = cuts(false)
@Test fun reducedMotionKeepsTheSameSingleFrameCuts() = cuts(true)
private fun cuts(reduced: Boolean) {
host = PolishAppHost(rule)
host.mount(picker = false, reduced = reduced)
rule.mainClock.autoAdvance = false
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
val samples = mutableListOf<String>()
val homeCard = SemanticsMatcher("Home card") {
it.config.contains(androidx.compose.ui.semantics.SemanticsProperties.TestTag) &&
it.config[androidx.compose.ui.semantics.SemanticsProperties.TestTag].startsWith("home-card:")
}
fun strip(phase: String) {
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
repeat(21) { index ->
rule.mainClock.advanceTimeByFrame()
val clock = rule.mainClock.currentTime
rule.onRoot().captureRoboImage(File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png").absolutePath)
assertEquals("Native drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime)
val shells = rule.onAllNodesWithTag("wolfskin-shell").fetchSemanticsNodes().size
val titles = rule.onAllNodesWithTag("title-page").fetchSemanticsNodes().size
val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size
assertEquals("Only the live destination may own a nav bar", 1, shells)
assertEquals(if (phase == "open-title") 1 else 0, titles)
if (phase != "back") assertEquals("Outgoing Home cards cannot survive the cut", 0, cards)
else assertTrue("Back immediately paints Home's shelves", cards > 0)
samples.add(phase + "," + index * 16 + "," + shells + "," + titles + "," + cards)
}
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L); pressKey(Key.DirectionCenter, 0L) }
strip("open-title")
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
strip("back")
rule.runOnIdle { host.session.state!!.focusNav(2); host.session.state!!.key(ShellKey.Ok) }
strip("tab")
File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards\n" + samples.joinToString("\n"))
}
@Test fun playbackVeilIgnoresKeysAndBackThenReturnsBackOwnershipToHome() {
host = PolishAppHost(rule)
host.mount(picker = false)
rule.mainClock.autoAdvance = false
var launches = 0
rule.runOnIdle { host.session.playback.begin("hero-play"); host.session.playback.ready { launches++ } }
rule.mainClock.advanceTimeBy(32)
val revision = host.session.state!!.inputRevision
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter); pressKey(Key.Escape) }
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertEquals(revision, host.session.state!!.inputRevision)
assertEquals(0, launches)
rule.mainClock.advanceTimeBy(200)
assertEquals(1, launches)
assertNull(host.session.playback.starting)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertTrue(host.session.state!!.navFocused)
assertFalse(rule.activity.isFinishing)
}
@Test fun backBeforeTheBlackVeilCancelsThePendingStartWithoutExitingHome() {
host = PolishAppHost(rule)
host.mount(picker = false)
rule.runOnIdle { host.session.playback.begin("hero-play") }
rule.mainClock.advanceTimeBy(32)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertNull(host.session.playback.starting)
assertTrue(host.session.state!!.navFocused)
assertFalse(rule.activity.isFinishing)
}
@Test fun setupHostReplacesServerAndUserScreensInOneFrame() = setupCuts(false)
@Test fun reducedMotionSetupHostAlsoCutsInOneFrame() = setupCuts(true)
private fun setupCuts(reduced: Boolean) {
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null)
val stack = mutableStateListOf<SetupDestination>(SetupDestination.ServerList)
val screensaver = mockk<ScreensaverService>()
every { screensaver.state } returns kotlinx.coroutines.flow.MutableStateFlow(ScreensaverState(false, false, false, false))
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { androidx.tv.material3.MaterialTheme {
com.github.damontecres.wholphin.MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
mockk(relaxed = true), screensaver,
Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight),
setupInterceptor = { destination, modifier ->
androidx.tv.material3.Text(destination.javaClass.simpleName, modifier)
true
})
} } }
rule.mainClock.autoAdvance = false
for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) {
val previous = stack.single().javaClass.simpleName
rule.runOnIdle { stack[0] = destination; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
val clock = rule.mainClock.currentTime
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, destination.javaClass.simpleName + ".png").absolutePath)
assertEquals("Native setup drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime)
rule.onNodeWithText(previous).assertDoesNotExist()
rule.onAllNodesWithText(destination.javaClass.simpleName).assertCountEquals(1)
}
}
}
/** Real shell integration with fixture data and an injectable saved-user switch. */
internal class PolishAppHost(private val rule: AndroidComposeTestRule<ActivityScenarioRule<ComponentActivity>, ComponentActivity>) : AutoCloseable {
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
var users = listOf("Justin", "Mindel", "Shared").mapIndexed { index, name ->
JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-00000000000${index + 2}"), serverId = server.id, name = name, accessToken = null)
}
val session = WolfskinSession()
val navigation = NavigationManager()
val repository = mockk<ServerRepository>(relaxed = true)
val setup = mockk<SetupNavigationManager>(relaxed = true)
private val conductarr = ConductarrSession(MemoryCacheStore())
private val models = mutableListOf<WolfskinShellViewModel>()
private var oldFixtures = false
private var oldScreens = true
private var current by mutableStateOf(users.first())
val model get() = models.last()
var reduceMotion by mutableStateOf(false)
fun showUser(user: JellyfinUser) { current = user }
fun mount(picker: Boolean = true, reduced: Boolean = false, fixtures: Boolean = true) {
reduceMotion = reduced
ConductarrPreference.initialize(rule.activity)
WolfskinScreenPreference.initialize(rule.activity)
oldFixtures = ConductarrPreference.fixtures
oldScreens = WolfskinScreenPreference.enabled
ConductarrPreference.updateFixtures(fixtures)
WolfskinScreenPreference.enabled = true
coEvery { repository.serverDao.getServer(server.id) } answers { JellyfinServerUsers(server, users) }
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
every { setup.navigateTo(any()) } answers {
val destination = firstArg<SetupDestination>()
if (destination is SetupDestination.AppContent) current = destination.current.user
}
rule.setContent {
CompositionLocalProvider(LocalReducedMotion provides reduceMotion) {
key(current.id) {
val model = remember {
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true),
org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
}
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
WolfskinApp(server, current, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), model,
homeContent = { shell, _, user, modifier ->
val snapshot = remember(user.id) { HomeFixture(rule.activity).snapshot(user.name!!.lowercase()) }
HomeScreen(shell, snapshot, modifier) { action ->
if (action is HomeAction.Details || action is HomeAction.Play)
navigation.navigateTo(Destination.Wolfskin(titleRoute("Locke")))
}
},
titleContent = { route, shell, _ ->
val title = remember(route) {
val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { org.json.JSONObject(it.readText()) })
TitleState(fixture.title("Locke", shell.person.key), shell.person) {
if (it == TitleAction.Pop) navigation.goBack()
}
}
androidx.activity.compose.BackHandler { title.back(shell) }
TitleSurface(title, shell)
})
}
}
}
await { session.state?.people?.size == users.size && model.signedInPerson == "justin" }
rule.runOnIdle { session.state!!.pickerVisible = picker; session.state!!.pickerLaunch = picker }
rule.mainClock.advanceTimeBy(1200)
}
fun await(condition: () -> Boolean) {
rule.waitUntil(10_000) {
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
shadowOf(Looper.getMainLooper()).idle()
if (!rule.mainClock.autoAdvance) rule.mainClock.advanceTimeByFrame()
condition()
}
rule.waitForIdle()
}
override fun close() {
models.forEach { it.viewModelScope.cancel() }
conductarr.close()
ConductarrPreference.updateFixtures(oldFixtures)
WolfskinScreenPreference.enabled = oldScreens
}
}

View file

@ -0,0 +1,185 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import androidx.activity.ComponentActivity
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.*
import java.io.File
import java.io.IOException
import kotlinx.coroutines.CompletableDeferred
import org.junit.*
import org.junit.Assert.*
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** A frozen, inert picker protects the selected identity across an asynchronous app replacement. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class PersonCommitTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var host: PolishAppHost
private val release = CompletableDeferred<Unit>()
@After fun dispose() { release.complete(Unit); if (::host.isInitialized) host.close() }
@Test fun choosingMindelCoversTheSwitchUntilTheirHomeCanPaint() = heldSwitch(false)
@Test fun reducedMotionUsesOnlyTheShortFullPickerFade() = heldSwitch(true)
private fun heldSwitch(reduced: Boolean) {
host = PolishAppHost(rule)
host.mount(reduced = reduced)
coEvery { host.repository.changeUser(host.server, any()) } coAnswers {
release.await()
CurrentUser(host.server, secondArg())
}
var pendingUser: JellyfinUser? = null
every { host.setup.navigateTo(any()) } answers { pendingUser = (firstArg() as SetupDestination.AppContent).current.user }
rule.mainClock.autoAdvance = false
press(listOf(Key.DirectionRight))
rule.mainClock.advanceTimeBy(400)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/P08/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "picker.png").absolutePath)
val commit = choose(listOf(Key.DirectionCenter))
val origin = commit.startFrameNanos
val clockOrigin = rule.mainClock.currentTime
val samples = mutableListOf<String>()
val others = mutableListOf<Int>()
val chosen = mutableListOf<Int>()
val bg = WolfskinTokens.Color.bg.toArgb()
repeat(31) { index ->
val timeMs = rule.mainClock.currentTime - clockOrigin
assertEquals("Frame strips advance only in 16 ms steps", index * 16L, timeMs)
rule.onNodeWithText("Sign in as", substring = true).assertDoesNotExist()
val file = File(directory, "commit-" + index.toString().padStart(2, '0') + ".png")
rule.onRoot().captureRoboImage(file.absolutePath)
assertEquals("Capturing a frame cannot advance animation time", timeMs, rule.mainClock.currentTime - clockOrigin)
val image = BitmapFactory.decodeFile(file.absolutePath)
try {
val other = image.getPixel(600, 500)
val selected = image.getPixel(900, 500)
others.add(android.graphics.Color.red(other))
chosen.add(selected)
if (timeMs >= if (reduced) 80 else 320) {
val pixels = IntArray(image.width * image.height)
image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height)
assertTrue("Only opaque background may remain while sign-in is pending", pixels.all { it == bg })
}
samples.add("$timeMs,$other,$selected")
} finally { image.recycle() }
rule.mainClock.advanceTimeByFrame()
}
assertTrue("The nonchosen person must fade monotonically to background", others.zipWithNext().all { (before, after) -> after <= before })
assertNotEquals(others.first(), others.last())
if (!reduced) assertTrue("The chosen avatar stays intact throughout phase one", chosen.take(10).all { it == chosen.first() })
File(directory, "samples.csv").writeText("timeMs,other,chosen\n" + samples.joinToString("\n"))
val shell = host.session.state!!
val revision = shell.inputRevision
val stack = host.navigation.backStack.toList()
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.Escape))
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
rule.onRoot().performTouchInput { click(androidx.compose.ui.geometry.Offset(600f, 500f)) }
assertEquals(revision, shell.inputRevision)
assertEquals("mindel", shell.person.key)
assertEquals(stack, host.navigation.backStack.toList())
if (reduced) {
rule.runOnIdle { host.reduceMotion = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
val file = File(directory, "motion-toggle.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val image = BitmapFactory.decodeFile(file.absolutePath)
try {
assertEquals("A completed veil cannot uncover the chosen person when motion changes", bg, image.getPixel(900, 500))
assertEquals(bg, image.getPixel(100, 80))
} finally { image.recycle() }
}
release.complete(Unit)
host.await { pendingUser != null }
rule.mainClock.advanceTimeBy(160)
assertNotNull("Completing switch work cannot uncover an unauthenticated replacement app", host.session.personCommit)
rule.runOnIdle { host.showUser(pendingUser!!) }
host.await { host.model.signedInPerson == "mindel" && host.session.personCommit == null }
assertEquals(origin, commit.startFrameNanos)
rule.onAllNodesWithText("Sex and the City")[0].assertIsDisplayed()
rule.onRoot().captureRoboImage(File(directory, "home-first-frame.png").absolutePath)
rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.heroFade.toLong() + 32)
rule.onRoot().captureRoboImage(File(directory, "home.png").absolutePath)
coVerify(exactly = 1) { host.repository.changeUser(host.server, any()) }
}
@Test fun samePersonRunsTheChoreographyWithoutSwitchingCredentials() {
host = PolishAppHost(rule)
host.mount()
rule.mainClock.autoAdvance = false
choose(listOf(Key.DirectionCenter))
assertNotNull(host.session.personCommit)
finishTimeline()
assertNull(host.session.personCommit)
rule.onAllNodesWithText("Yu Yu Hakusho")[0].assertIsDisplayed()
coVerify(exactly = 0) { host.repository.changeUser(any(), any()) }
}
@Test fun pinProtectedPersonReturnsToSetupWithoutChangingCredentials() = protectedPerson(pin = "protected")
@Test fun requireLoginPersonReturnsToSetupWithoutChangingCredentials() = protectedPerson(requireLogin = true)
private fun protectedPerson(pin: String? = null, requireLogin: Boolean = false) {
host = PolishAppHost(rule)
host.users = host.users.mapIndexed { index, user -> if (index == 1) user.copy(pin = pin, requireLogin = requireLogin) else user }
host.mount(fixtures = false)
rule.mainClock.autoAdvance = false
choose(listOf(Key.DirectionRight, Key.DirectionCenter))
assertNotNull(host.session.personCommit)
awaitSwitchWork()
verify(exactly = 0) { host.setup.navigateTo(any()) }
finishTimeline()
assertNull(host.session.personCommit)
coVerify(exactly = 0) { host.repository.changeUser(any(), any()) }
verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) }
}
@Test fun failedCredentialSwitchRevealsSetupOnlyAfterThePickerIsCovered() {
host = PolishAppHost(rule)
host.mount(fixtures = false)
coEvery { host.repository.changeUser(any(), any()) } throws IOException("switch failed")
rule.mainClock.autoAdvance = false
choose(listOf(Key.DirectionRight, Key.DirectionCenter))
awaitSwitchWork()
finishTimeline()
assertNull(host.session.personCommit)
verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) }
}
private fun choose(keys: List<Key>): PersonCommit {
press(keys)
val commit = requireNotNull(host.session.personCommit)
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeUntil(1000) { commit.startFrameNanos != null }
return commit
}
private fun finishTimeline() {
repeat(WolfskinTokens.Motion.base * 2 / 16 + 2) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
}
private fun awaitSwitchWork() {
rule.waitUntil(5000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
host.session.personCommit?.switchEnded == true
}
}
private fun press(keys: List<Key>) = rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it, 0L) } }
}

View file

@ -219,7 +219,7 @@ class PersonSwitchThreadingTest {
coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary()
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, WolfskinSession())
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true))
try {
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)

View file

@ -52,7 +52,7 @@ class PlayerHandoffTest {
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false }
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
}
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
@ -78,15 +78,30 @@ class PlayerHandoffTest {
}
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
val snapshot = HomeFixture(rule.activity).snapshot("mindel")
val title = snapshot.rows.first().cards.first()
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto(
id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE,
userData = playbackUserData(id, 11_362_740_000L, playCount = 2))
val view = shell.view
view.cards["hero"] = 0
rule.setContent { androidx.compose.runtime.CompositionLocalProvider(LocalHomePlaybackStart provides model.session.playback) { WolfskinTheme {
androidx.compose.foundation.layout.Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) {
ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } }
PlaybackHandoffVeil(model.session.playback)
}
} } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) }
awaitModel { navigation.backStack.last() is Destination.Playback }
awaitModel { model.session.playback.launch != null }
assertTrue("Preparation must not navigate before the veil", navigation.backStack.single() is Destination.Home)
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeBy(80)
assertTrue("The first half of the fade must still belong to Home", navigation.backStack.single() is Destination.Home)
rule.mainClock.advanceTimeBy(192)
assertEquals(2, navigation.backStack.size)
val destination = navigation.backStack.last() as Destination.Playback
assertEquals(id, destination.itemId)
assertEquals(1_136_274L, destination.positionMs)

View file

@ -115,7 +115,7 @@ class WatchReadTest {
val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns (HomeLibrary(resume = listOf(HomeTitle(resume.id.toString(), "Cached episode", "episode", resumeSeconds = 10.0))) to 1L)
val session = ConductarrSession(MemoryCacheStore())
val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session)
val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession())
try {
val loading = launch { model.loadLibrary(server, user, person) }
runCurrent()

View file

@ -47,7 +47,7 @@ class WatchReturnTest {
val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val session = ConductarrSession(MemoryCacheStore())
val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session)
val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession())
val oldFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(false)
try {