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()
}
}
}