Fix TV episode navigation and live resume freshness
This commit is contained in:
parent
54c23e076a
commit
8e40ac0869
34 changed files with 793 additions and 101 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
135
app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt
Normal file
135
app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue