Candidate 3: merge tv-bug-season-reel
This commit is contained in:
commit
fad0ca672c
3 changed files with 181 additions and 1 deletions
|
|
@ -0,0 +1,169 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.github.damontecres.wholphin.WholphinApplication
|
||||||
|
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||||
|
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.ImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.ui.successQueryResult
|
||||||
|
import com.github.damontecres.wholphin.ui.successResponse
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.unmockkObject
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
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.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.design.ShellPerson
|
||||||
|
import org.justinvisser.wolfskin.design.ShellState
|
||||||
|
import org.justinvisser.wolfskin.design.TitleAction
|
||||||
|
import org.justinvisser.wolfskin.design.TitlePageData
|
||||||
|
import org.justinvisser.wolfskin.design.TitleState
|
||||||
|
import org.justinvisser.wolfskin.design.TitleSurface
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||||
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** The actual SDK projection and native reel must agree on chronology and the activated episode. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class TitleSeasonReelTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
@get:Rule val directory = TemporaryFolder()
|
||||||
|
private val seriesId = UUID(0, 1)
|
||||||
|
private val series = BaseItemDto(id = seriesId, name = "Desperate Housewives", type = BaseItemKind.SERIES,
|
||||||
|
providerIds = mapOf("Tmdb" to "693"), people = emptyList())
|
||||||
|
private val episodes = (1..24).map { number ->
|
||||||
|
BaseItemDto(id = UUID(0, 100L + number), name = "Episode $number", type = BaseItemKind.EPISODE,
|
||||||
|
seriesId = seriesId, parentIndexNumber = 5, indexNumber = number,
|
||||||
|
providerIds = mapOf("Tmdb" to number.toString()), people = emptyList(),
|
||||||
|
userData = playbackUserData(UUID(0, 100L + number), 0, played = number < 24))
|
||||||
|
}
|
||||||
|
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||||
|
private lateinit var facade: TitleReadFacade
|
||||||
|
private lateinit var state: TitleState
|
||||||
|
private val actions = mutableListOf<TitleAction>()
|
||||||
|
|
||||||
|
@Before fun setup() {
|
||||||
|
mockkObject(WholphinApplication.Companion)
|
||||||
|
every { WholphinApplication.instance } returns mockk {
|
||||||
|
every { resources } returns rule.activity.resources
|
||||||
|
}
|
||||||
|
val library = mockk<UserLibraryApi>()
|
||||||
|
val items = mockk<ItemsApi>()
|
||||||
|
val shows = mockk<TvShowsApi>()
|
||||||
|
val api = mockk<ApiClient>()
|
||||||
|
every { api.userLibraryApi } returns library
|
||||||
|
every { api.itemsApi } returns items
|
||||||
|
every { api.tvShowsApi } returns shows
|
||||||
|
coEvery { library.getItem(seriesId) } returns successResponse(series)
|
||||||
|
episodes.forEach { episode -> coEvery { library.getItem(episode.id) } returns successResponse(episode) }
|
||||||
|
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult((1..8).map { number ->
|
||||||
|
BaseItemDto(id = UUID(0, 10L + number), type = BaseItemKind.SEASON, indexNumber = number)
|
||||||
|
})
|
||||||
|
coEvery { shows.getEpisodes(any<GetEpisodesRequest>()) } returns successQueryResult(episodes)
|
||||||
|
coEvery { shows.getNextUp(any<GetNextUpRequest>()) } returns successQueryResult(listOf(episodes.last()))
|
||||||
|
val playback = mockk<ItemPlaybackRepository>()
|
||||||
|
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
|
||||||
|
val repository = mockk<ServerRepository> {
|
||||||
|
every { currentServer } returns null
|
||||||
|
every { currentUser } returns null
|
||||||
|
}
|
||||||
|
val context = mockk<Context> { every { filesDir } returns directory.root }
|
||||||
|
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk(), context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun teardown() { unmockkObject(WholphinApplication.Companion) }
|
||||||
|
|
||||||
|
private fun render(page: TitlePageData) {
|
||||||
|
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
|
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
||||||
|
pickerVisible = false; pickerLaunch = false; enterDestination("title/series")
|
||||||
|
}
|
||||||
|
state = TitleState(page, viewer, actions::add)
|
||||||
|
rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } }
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
repeat(4) { press(Key.DirectionLeft) }
|
||||||
|
assertEquals("episodes", state.row)
|
||||||
|
assertEquals(0, state.column)
|
||||||
|
rule.onNodeWithTag("title-page").assertIsFocused()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun nextUpAtSeasonEndCannotMoveTheFinalEpisodeToTheStartOfTheReel() = runBlocking<Unit> {
|
||||||
|
val page = facade.read(seriesId, 5, preferences)
|
||||||
|
render(page)
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals("The first tile must open E1, not next-up E24", episodes.first().id.toString(), openedEpisode())
|
||||||
|
assertEquals("Play remains next-up even though the reel is chronological", episodes.last().id.toString(), page.playTarget?.id)
|
||||||
|
assertEquals((1..24).toList(), page.episodes.map { it.episode })
|
||||||
|
val saved = requireNotNull(facade.readSaved(seriesId, preferences))
|
||||||
|
assertEquals(page.episodes.map { it.id }, saved.episodes.map { it.id })
|
||||||
|
repeat(7) { press(Key.DirectionRight) }
|
||||||
|
repeat(2) { press(Key.DirectionLeft) }
|
||||||
|
press(Key.DirectionUp)
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals("Row return and Left/Right preserve E6", episodes[5].id.toString(), openedEpisode())
|
||||||
|
rule.onNodeWithTag("title-episode-slot-5").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun openingAnotherEpisodeAndRefreshingCannotReorderTheRememberedReel() = runBlocking<Unit> {
|
||||||
|
val opened = facade.read(episodes[11].id, 5, preferences)
|
||||||
|
render(opened)
|
||||||
|
repeat(12) { press(Key.DirectionRight) }
|
||||||
|
assertEquals(episodes[12].id.toString(), state.data.episodes[state.column].id)
|
||||||
|
val refreshed = facade.read(seriesId, 5, preferences)
|
||||||
|
rule.runOnIdle { state.update(refreshed) }
|
||||||
|
press(Key.DirectionCenter)
|
||||||
|
assertEquals("Refresh must not change E13 into another episode", episodes[12].id.toString(), openedEpisode())
|
||||||
|
assertEquals(episodes[11].id.toString(), opened.playTarget?.id)
|
||||||
|
assertEquals(episodes[11].id.toString(), opened.watchTarget.id)
|
||||||
|
assertEquals(opened.episodes.map { it.id }, refreshed.episodes.map { it.id })
|
||||||
|
rule.onNodeWithTag("title-episode-slot-12").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openedEpisode() = (actions.last() as TitleAction.OpenTitle).item.id
|
||||||
|
private fun press(key: Key) {
|
||||||
|
rule.onRoot().performKeyInput { pressKey(key) }
|
||||||
|
rule.mainClock.advanceTimeBy(400)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -192,6 +192,9 @@ class TitleReadFacade @Inject constructor(
|
||||||
streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) },
|
streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) },
|
||||||
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
|
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
|
||||||
}
|
}
|
||||||
|
// Rich playback/watch rows are prefixed in storage, not in the season's display order.
|
||||||
|
val seasonEpisodes = rows.filterTo(ArrayList()) { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }
|
||||||
|
seasonEpisodes.sortBy { it.indexNumber }
|
||||||
val pageItem = mapItem(item).copy(seasons = seasonNumbers.size)
|
val pageItem = mapItem(item).copy(seasons = seasonNumbers.size)
|
||||||
return TitlePageData(pageItem, target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it, pageItem.backdrop) },
|
return TitlePageData(pageItem, target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it, pageItem.backdrop) },
|
||||||
rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
|
rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
|
||||||
|
|
@ -200,7 +203,7 @@ class TitleReadFacade @Inject constructor(
|
||||||
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
|
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
|
||||||
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
|
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
|
||||||
},
|
},
|
||||||
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) },
|
episodes = seasonEpisodes.map { mapItem(it, pageItem.backdrop) },
|
||||||
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
|
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1007,6 +1007,14 @@ Episode shelf skeletons use the same width, height and pitch as ready cards.
|
||||||
Person filmography and related-title shelves remain posters; Home continuation
|
Person filmography and related-title shelves remain posters; Home continuation
|
||||||
tiles are a separate component and retain their series-name context.
|
tiles are a separate component and retain their series-name context.
|
||||||
|
|
||||||
|
Season episode reels use episode-number order independently of the Play/Resume
|
||||||
|
or explicitly opened episode. The shared live/saved projection keeps rich
|
||||||
|
playback metadata without promoting that target into the first tile. A refresh
|
||||||
|
therefore cannot replace the episode under a remembered reel index merely
|
||||||
|
because the playback target changed. `TitleSeasonReelTest` replays native remote
|
||||||
|
input on SDK 34 and 36 with 24 season-five episodes and next-up at E24, including
|
||||||
|
row return, exact activation, cached reads and an explicit-episode refresh.
|
||||||
|
|
||||||
|
|
||||||
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
|
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
|
||||||
person credits and playback mutations to one boundary. `ConductarrTitlePages`
|
person credits and playback mutations to one boundary. `ConductarrTitlePages`
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue