Fix TV episode navigation and live resume freshness

This commit is contained in:
justinzeus 2026-11-06 22:41:16 +01:00
parent 54c23e076a
commit 8e40ac0869
34 changed files with 793 additions and 101 deletions

View file

@ -114,6 +114,7 @@ class PlayExternalViewModelTest {
every { api.subtitleApi } returns subtitleApi
every { api.playStateApi } returns playStateApi
every { serverRepository.currentUser } returns null
every { serverRepository.currentServer } returns null
coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie)
coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource
coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns

View file

@ -0,0 +1,95 @@
package com.github.damontecres.wholphin.util
import android.app.Application
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import androidx.media3.common.Player
import io.mockk.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.*
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.operations.PlayStateApi
import org.jellyfin.sdk.model.api.PlayMethod
import com.github.damontecres.wholphin.ui.successResponse
import org.junit.Assert.*
import org.junit.Test
import java.util.UUID
import org.justinvisser.wolfskin.conductarr.FileCacheStore
import org.justinvisser.wolfskin.conductarr.CacheEntry
import java.io.File
/** Return refresh waits for the stop write rather than racing its acknowledgement. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class PlaybackWatchStateTest {
@Test fun revisionAppearsOnlyAfterTheCapturedUsersStopIsAcknowledged() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val previous = WholphinDispatchers.IO
Dispatchers.setMain(dispatcher)
WholphinDispatchers.IO = dispatcher
val api = mockk<ApiClient>()
val reports = mockk<PlayStateApi>()
every { api.playStateApi } returns reports
val player = mockk<Player> { every { currentPosition } returns 751_246L }
val scope = "server:${UUID.randomUUID()}"
val another = "server:${UUID.randomUUID()}"
val context = RuntimeEnvironment.getApplication()
val cache = FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))
val saved = CacheEntry("old progress", null, 1L, 1L, group = "library")
cache.write(scope, "library:movies", saved)
cache.write(another, "library:movies", saved)
val acknowledgement = CompletableDeferred<Unit>()
coEvery { reports.reportPlaybackStopped(any()) } coAnswers {
acknowledgement.await()
successResponse(Unit)
}
val state = PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY)
val listener = TrackActivityPlaybackListener(api, player, onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { state })
try {
listener.release()
runCurrent()
assertNull(PlaybackWatchState.revisions.value[scope])
assertNotNull(cache.read(scope, "library:movies"))
acknowledgement.complete(Unit)
advanceUntilIdle()
assertNotNull(PlaybackWatchState.revisions.value[scope])
assertNull(PlaybackWatchState.revisions.value[another])
assertNull(cache.read(scope, "library:movies"))
assertEquals(saved, cache.read(another, "library:movies"))
coVerify(exactly = 1) { reports.reportPlaybackStopped(match { it.itemId == state.itemId && it.positionTicks == 7_512_460_000L }) }
listener.release()
advanceUntilIdle()
coVerify(exactly = 1) { reports.reportPlaybackStopped(any()) }
} finally {
WholphinDispatchers.IO = previous
Dispatchers.resetMain()
}
}
@Test fun failedStopDoesNotPublishAFictitiousAcknowledgement() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val previous = WholphinDispatchers.IO
Dispatchers.setMain(dispatcher)
WholphinDispatchers.IO = dispatcher
val api = mockk<ApiClient>()
val reports = mockk<PlayStateApi>()
every { api.playStateApi } returns reports
coEvery { reports.reportPlaybackStopped(any()) } throws java.io.IOException("Stop rejected")
val scope = "server:${UUID.randomUUID()}"
val listener = TrackActivityPlaybackListener(api, mockk<Player> { every { currentPosition } returns 751_246L },
onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY) })
try {
listener.release()
advanceUntilIdle()
assertNull(PlaybackWatchState.revisions.value[scope])
} finally {
WholphinDispatchers.IO = previous
Dispatchers.resetMain()
}
}
}

View file

@ -52,8 +52,9 @@ class ConductarrHomeSurfaceTest {
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val home = mockk<HomeReadFacade>()
coEvery { home.read(user.id) } returns HomeLibrary(resume = listOf(
coEvery { home.readWatch(user.id) } returns HomeLibrary(resume = listOf(
HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true)))
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)

View file

@ -43,11 +43,13 @@ class HomeRecoveryTest {
val started = CompletableDeferred<Unit>()
val response = CompletableDeferred<HomeLibrary>()
val facade = mockk<HomeReadFacade>()
coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() }
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 refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
started.await()
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
assertFalse(model.snapshot.rows.first().watchReady)
response.completeExceptionally(IOException("fixture offline"))
refresh.await()
assertFalse(model.snapshot.jellyfinAvailable)
@ -62,8 +64,9 @@ class HomeRecoveryTest {
cache.write("${server.id}:${personA.id}", library, 1000L)
val startedA = CompletableDeferred<Unit>()
val responseA = CompletableDeferred<HomeLibrary>()
coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
coEvery { facade.read(personB.id) } throws IOException("fixture offline")
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 requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
startedA.await()
@ -80,7 +83,8 @@ class HomeRecoveryTest {
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
val facade = mockk<HomeReadFacade>()
coEvery { facade.read(personA.id) } returns library
coEvery { facade.readWatch(personA.id) } returns library
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
val cache = mockk<HomeLibraryCache>()
every { cache.read(any()) } returns null
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
@ -93,7 +97,8 @@ class HomeRecoveryTest {
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
val facade = mockk<HomeReadFacade>()
coEvery { facade.read(personA.id) } returns library
coEvery { facade.readWatch(personA.id) } returns library
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()))

View file

@ -48,6 +48,21 @@ class LibrarySnapshotStoreTest {
assertFalse(reads.last().unreachable)
}
@Test fun acknowledgedPlaybackInvalidatesOnlyOlderWatchSnapshotsBeforeTheTtl() = runBlocking {
var now = 1000L
val store = LibrarySnapshotStore(MemoryCacheStore()) { now }
val old = listOf(LibraryTitle("film", "Film", "movie", progress = .1f))
store.read("server:justin", "movies") { flowOf(old) }.toList()
store.read("server:mindel", "movies") { flowOf(old) }.toList()
now = 1002L
val updated = listOf(old.single().copy(progress = .6f))
val reads = store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { flowOf(updated) }.toList()
assertEquals(.1f, reads.first().items.single().progress)
assertEquals(.6f, reads.last().items.single().progress)
assertEquals(updated, store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { error("Already revalidated") }.toList().single().items)
assertEquals(old, store.read("server:mindel", "movies") { error("Other viewer stays cached") }.toList().single().items)
}
@Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking {
val partial = listOf(LibraryTitle("partial", "First film", "movie"))
val complete = partial + LibraryTitle("last", "Last film", "movie")

View file

@ -214,8 +214,9 @@ class PersonSwitchThreadingTest {
every { repository.currentUser } returns user
every { repository.currentServer } returns server
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
coEvery { home.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf(
coEvery { home.readWatch(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf(
org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true)))
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)

View file

@ -0,0 +1,9 @@
package org.justinvisser.wolfskin.tv
import org.jellyfin.sdk.model.api.UserItemDataDto
import java.util.UUID
/** Checkpoints use the SDK's required identity and watch fields. */
internal fun playbackUserData(itemId: UUID, ticks: Long, played: Boolean = false, playCount: Int = 0): UserItemDataDto =
UserItemDataDto(playbackPositionTicks = ticks, playCount = playCount, isFavorite = false,
lastPlayedDate = null, played = played, key = "", itemId = itemId)

View file

@ -58,7 +58,7 @@ class PlayerHandoffTest {
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
val title = snapshot.rows.first().cards.first()
coEvery { facade.playableId(user.id, title) } throws IOException("fixture offline")
coEvery { facade.preparePlayback(user.id, title) } throws IOException("fixture offline")
rule.setContent { WolfskinTheme {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
@ -80,14 +80,16 @@ class PlayerHandoffTest {
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
coEvery { facade.playableId(user.id, title) } returns id
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.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) }
rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) }
awaitModel { navigation.backStack.last() is Destination.Playback }
val destination = navigation.backStack.last() as Destination.Playback
assertEquals(id, destination.itemId)
assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs)
assertEquals(1_136_274L, destination.positionMs)
assertTrue(destination.fullScreen)
navigation.goBack()
assertTrue(navigation.backStack.single() is Destination.Home)
@ -99,11 +101,11 @@ class PlayerHandoffTest {
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
val otherUser = user.copy(name = "Justin")
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, otherUser) }
rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, otherUser) }
awaitModel { model.toast != null }
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
assertTrue(navigation.backStack.single() is Destination.Home)
coVerify(exactly = 0) { facade.playableId(any(), any()) }
coVerify(exactly = 0) { facade.preparePlayback(any(), any()) }
}
private fun awaitModel(condition: () -> Boolean) {
rule.waitUntil(5000) {

View file

@ -11,6 +11,9 @@ import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
@ -62,6 +65,27 @@ class TitleKeysTest {
} }
}
@Test fun downFocusesThePlayableLaterSeasonAndScrollsItIntoViewWithoutLosingBrowsingMemory() {
render("Andor") { page ->
page.copy(item = page.item.copy(seasons = 30), seasonNumbers = (1..30).toList(), selectedSeason = 29,
playTarget = page.playTarget!!.copy(season = 29))
}
press(Key.DirectionDown)
assertEquals("seasons", state.row)
assertEquals(28, state.column)
rule.onNode(hasText("Season 29") and hasClickAction()).assertIsDisplayed()
press(Key.DirectionLeft)
assertEquals(27, state.column)
rule.onNode(hasText("Season 28") and hasClickAction()).assertIsDisplayed()
press(Key.DirectionUp)
press(Key.DirectionDown)
assertEquals(27, state.column)
press(Key.DirectionCenter)
assertEquals(28, state.season)
assertEquals(28, state.requestedSeason)
assertEquals(TitleAction.Season(28), effects.last())
}
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
private fun replay(end: String): Int {
val path = paths.single { it.getString("end") == end }

View file

@ -0,0 +1,136 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
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.nav.Destination
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.junit.Assert.*
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.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.UUID
/** Production title actions preserve parents and resolve live playback instead of cached offsets. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class TitlePlaybackNavigationTest {
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001")
private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002")
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003")
private val person = ShellPerson("justin", "Justin", "J", false)
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
private val series = TitleItem(seriesId.toString(), "Series", "tv", seasons = 5, held = true)
private val first = TitleItem(firstId.toString(), "First episode", "episode", held = true, season = 3, episode = 1, resumeSeconds = 12)
private val second = first.copy(id = secondId.toString(), name = "Second episode", episode = 2)
@Test fun repeatedEpisodeChoicesReplaceOnlyEpisodesAndBackPreservesTheirSeriesParent() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val navigation = NavigationManager()
val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(any(), any()) } returns null
coEvery { facade.read(any(), any(), any()) } coAnswers {
val id = firstArg<UUID>()
TitlePageData(series, playTarget = first, watchTarget = if (id == seriesId) series else first.copy(id = id.toString()),
episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor")))
}
val session = ConductarrSession(MemoryCacheStore())
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true))
fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true)
try {
navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString())))
open(seriesId)
advanceUntilIdle()
model.title!!.click("episodes", 0)
assertEquals(3, navigation.backStack.size)
assertEquals(firstId, (navigation.backStack.last() as Destination.MediaItem).itemId)
open(firstId)
advanceUntilIdle()
for (id in listOf(secondId, firstId, secondId)) {
model.title!!.click("episodes", if (id == firstId) 0 else 1)
assertEquals(3, navigation.backStack.size)
assertEquals(id, (navigation.backStack.last() as Destination.MediaItem).itemId)
open(id)
advanceUntilIdle()
}
model.title!!.click("related", 0)
assertEquals(4, navigation.backStack.size)
navigation.goBack()
model.title!!.click("cast", 0)
assertEquals(4, navigation.backStack.size)
navigation.goBack()
model.title!!.back(ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false })
assertEquals(listOf(Destination.Home(), Destination.Wolfskin(titleRoute(seriesId.toString()))), navigation.backStack.toList())
navigation.goBack()
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
navigation.navigateTo(Destination.Wolfskin(titleRoute(firstId.toString())))
open(firstId)
advanceUntilIdle()
model.title!!.click("episodes", 1)
navigation.goBack()
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
} finally {
model.viewModelScope.cancel()
session.close()
Dispatchers.resetMain()
}
}
@Test fun primarySeriesActionReResolvesNextUpButRestartAndExplicitEpisodesKeepTheirIdentity() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val navigation = NavigationManager()
val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(any(), any()) } returns null
coEvery { facade.read(any(), any(), any()) } coAnswers {
TitlePageData(series, first, watchTarget = if (firstArg<UUID>() == seriesId) series else first)
}
coEvery { facade.preparePlayback(seriesId) } returns BaseItemDto(id = secondId, type = BaseItemKind.EPISODE,
userData = playbackUserData(secondId, 11_362_740_000L, playCount = 2))
coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE,
userData = playbackUserData(firstId, 960_780_000L, playCount = 1))
val session = ConductarrSession(MemoryCacheStore())
val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk<ServerRepository>(relaxed = true))
try {
model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false)
advanceUntilIdle()
model.title!!.click("header", 0)
advanceUntilIdle()
assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last())
navigation.goBack()
model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" })
advanceUntilIdle()
assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last())
navigation.goBack()
model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false)
advanceUntilIdle()
model.title!!.click("header", 0)
advanceUntilIdle()
assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last())
coVerify(exactly = 1) { facade.preparePlayback(seriesId) }
} finally {
model.viewModelScope.cancel()
session.close()
Dispatchers.resetMain()
}
}
}

View file

@ -128,7 +128,7 @@ class TitleReadFacadeTest {
}
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
coEvery { library.getItem(firstId) } returns successResponse(file(firstId).copy(userData = playbackUserData(firstId, 200_000_000L)))
facade.read(firstId, null, preferences)
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
val saved = facade.readSaved(firstId, preferences)!!
@ -139,6 +139,7 @@ class TitleReadFacadeTest {
assertEquals(TitleConnection.SignedOut, saved.connection)
assertTrue(saved.item.ratings.isEmpty())
assertEquals(firstId.toString(), saved.watchTarget.id)
assertFalse(screen.options.any { it.id == "play" || it.id == "restart" })
}
@Test fun diskCopiesNeverCrossViewerIdentity() = runTest {

View file

@ -0,0 +1,135 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.content.Context
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.ui.successQueryResult
import com.github.damontecres.wholphin.ui.successResponse
import io.mockk.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.*
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.TvShowsApi
import org.jellyfin.sdk.api.operations.UserLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.BaseItemKind
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
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.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import java.util.UUID
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
/** Recorded Jellyfin watch shapes exercise newer checkpoints and independent Home publication. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class WatchReadTest {
private val api = mockk<ApiClient>()
private val library = mockk<UserLibraryApi>()
private val shows = mockk<TvShowsApi>()
private val items = mockk<ItemsApi>()
private val images = mockk<ImageUrlService>(relaxed = true)
private val userId = UUID.fromString("167649f8-00c9-44b5-a86a-f371eccc2d2e")
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003")
private val json = Json { ignoreUnknownKeys = true }
// Selected fields from the read-only Resume and NextUp replies, without credentials.
private val resumeWire = """{"Items":[{"Id":"a8ed46755a50a5dfb443dd5cdd7125a3","Name":"Sex and the Country","Type":"Episode","ParentIndexNumber":4,"IndexNumber":9,"RunTimeTicks":17507200000,"UserData":{"PlaybackPositionTicks":960780000,"Played":false,"PlayCount":1,"IsFavorite":false,"LastPlayedDate":null,"Key":"","ItemId":"a8ed46755a50a5dfb443dd5cdd7125a3"}}],"TotalRecordCount":1,"StartIndex":0}"""
private val nextWire = """{"Items":[{"Id":"d6a9588e91a4a22ecb9ae279eb7e09d6","Name":"Me and My Town","Type":"Episode","SeriesName":"Desperate Housewives","ParentIndexNumber":5,"IndexNumber":9,"RunTimeTicks":25368960000,"UserData":{"PlaybackPositionTicks":11362740000,"Played":false,"PlayCount":2,"IsFavorite":false,"LastPlayedDate":"2026-11-06T10:39:27.1772471Z","Key":"","ItemId":"d6a9588e91a4a22ecb9ae279eb7e09d6"}}],"TotalRecordCount":1,"StartIndex":0}"""
private fun decode(wire: String) = json.decodeFromString(BaseItemDtoQueryResult.serializer(), wire).items.first()
@Before fun setup() {
every { api.userLibraryApi } returns library
every { api.tvShowsApi } returns shows
every { api.itemsApi } returns items
mockkObject(WholphinApplication.Companion)
every { WholphinApplication.instance } returns mockk {
every { resources } returns RuntimeEnvironment.getApplication().resources
}
}
@After fun teardown() { unmockkObject(WholphinApplication.Companion) }
@Test fun bothFacadesUseTheLiveCheckpointAndSeriesNextUpWhileExplicitEpisodeKeepsItsIdentity() = runTest {
val resume = decode(resumeWire)
val next = decode(nextWire)
val series = BaseItemDto(id = seriesId, type = BaseItemKind.SERIES)
coEvery { library.getItem(itemId = seriesId, userId = userId) } returns successResponse(series)
coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(resume)
coEvery { library.getItem(itemId = next.id, userId = userId) } returns successResponse(next)
coEvery { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) } returns successQueryResult(listOf(next))
val home = HomeReadFacade(api, images, mockk())
val repository = mockk<ServerRepository> {
every { currentUser } returns mockk { every { id } returns userId }
}
val title = TitleReadFacade(api, images, mockk(), repository, mockk(), mockk<Context>())
val cached = HomeTitle(resume.id.toString(), "Sex and the Country", "episode", resumeSeconds = 10.0)
assertEquals(96_078L, home.preparePlayback(userId, cached)!!.playbackPositionMs())
assertEquals(96_078L, title.preparePlayback(resume.id)!!.playbackPositionMs())
assertEquals(next.id, home.preparePlayback(userId, HomeTitle(seriesId.toString(), "Series", "tv"))!!.id)
val selected = title.preparePlayback(seriesId)!!
assertEquals(next.id, selected.id)
assertEquals(1_136_274L, selected.playbackPositionMs())
coVerify(exactly = 2) { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) }
val completed = resume.copy(userData = resume.userData!!.copy(playbackPositionTicks = 0, played = true))
coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(completed)
assertEquals(0L, home.preparePlayback(userId, cached)!!.playbackPositionMs())
assertEquals(true, title.preparePlayback(resume.id)!!.userData?.played)
}
@Test fun freshResumeAndNextUpPaintBeforeSlowLibraryAndSurviveItsFailure() = runTest {
WholphinDispatchers.configure(StandardTestDispatcher(testScheduler))
val server = JellyfinServer(seriesId, "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = userId, serverId = server.id, name = "Mindel", accessToken = null)
val person = ShellPerson("mindel", "Mindel", "M", false, userId.toString())
val resume = decode(resumeWire)
val next = decode(nextWire)
coEvery { items.getResumeItems(userId = userId, fields = any(), mediaTypes = any(), limit = 20) } returns successQueryResult(listOf(resume))
coEvery { shows.getNextUp(userId = userId, fields = any(), limit = 20, enableUserData = true) } returns successQueryResult(listOf(next))
val facade = spyk(HomeReadFacade(api, images, mockk()))
val releaseLibrary = CompletableDeferred<Unit>()
coEvery { facade.readLibrary(userId) } coAnswers { releaseLibrary.await(); throw java.io.IOException("Library failed") }
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)
try {
val loading = launch { model.loadLibrary(server, user, person) }
runCurrent()
assertFalse(loading.isCompleted)
assertTrue(model.snapshot.rows.first().watchReady)
assertEquals(1136.274, model.snapshot.rows.single { it.id == "nu" }.cards.single().resumeSeconds, .0001)
releaseLibrary.complete(Unit)
loading.join()
assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001)
assertTrue(model.snapshot.jellyfinAvailable)
} finally {
model.viewModelScope.cancel()
session.close()
WholphinDispatchers.reset()
}
}
}

View file

@ -0,0 +1,77 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.util.PlaybackWatchState
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.cancel
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.HomeLibraryCache
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import java.util.UUID
import java.util.concurrent.atomic.AtomicReference
/** A still-composed Home must paint the checkpoint acknowledged after its first return read. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class WatchReturnTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun acknowledgedStopRefreshesVisibleHeroWithoutRecreatingHomeOrLosingFocus() {
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Mindel", accessToken = null)
val person = ShellPerson("mindel", "Mindel", "M", false, user.id.toString())
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false }
val episode = HomeTitle("episode", "Me and My Town", "episode", runtimeSeconds = 2536, resumeSeconds = 10.0, held = true, seriesTitle = "Desperate Housewives", season = 5, episode = 9)
val current = AtomicReference(episode)
val facade = mockk<HomeReadFacade>()
every { facade.audiences } returns null
coEvery { facade.readWatch(user.id) } coAnswers { HomeLibrary(resume = listOf(current.get())) }
coEvery { facade.readLibrary(user.id) } returns HomeLibrary()
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 oldFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(false)
try {
rule.setContent { WolfskinTheme {
ShellSurface(shell) { HomeDestination(shell, server, user, it, model) }
} }
rule.waitUntil(5000) {
shadowOf(Looper.getMainLooper()).idle()
model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 10.0
}
val view = shell.rememberedView("home")
rule.runOnIdle { shell.navFocused = false; view.cards["hero"] = 0; current.set(episode.copy(resumeSeconds = 1136.274)) }
PlaybackWatchState.stopped(rule.activity, "${server.id}:${user.id}")
rule.waitUntil(5000) {
shadowOf(Looper.getMainLooper()).idle()
model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 1136.274
}
assertSame(view, shell.rememberedView("home"))
assertEquals(0, view.cards["hero"])
assertTrue(model.snapshot.rows.first().watchReady)
} finally {
model.viewModelScope.cancel()
session.close()
ConductarrPreference.updateFixtures(oldFixtures)
}
}
}