Round 2 release candidate 3: polish lanes A-E, season reel and trailer return #82
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.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)
|
||||
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,
|
||||
|
|
@ -200,7 +203,7 @@ class TitleReadFacade @Inject constructor(
|
|||
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) })
|
||||
},
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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,
|
||||
person credits and playback mutations to one boundary. `ConductarrTitlePages`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue