Preserve title data and focus across updates

This commit is contained in:
justinzeus 2026-11-05 17:13:53 +01:00
parent 0c94e8f910
commit 6d7df0fb19
31 changed files with 747 additions and 147 deletions

View file

@ -54,4 +54,17 @@ class ShellRoutingTest {
assertEquals(Destination.Search("name"), resolveUpstreamDestination(Destination.Search("name"), enabled, libraries))
}
}
@Test fun titleRolloutOwnsOnlyFilmSeriesEpisodeAndPerson() {
val id = UUID.fromString("00000000-0000-0000-0000-000000000001")
for (kind in listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.EPISODE, BaseItemKind.PERSON)) {
val destination = Destination.MediaItem(id, kind)
assertNull(resolveUpstreamDestination(destination, true, libraries))
assertEquals(destination, resolveUpstreamDestination(destination, false, libraries))
}
for (kind in listOf(BaseItemKind.COLLECTION_FOLDER, BaseItemKind.AUDIO, BaseItemKind.MUSIC_ALBUM, BaseItemKind.BOX_SET)) {
val destination = Destination.MediaItem(id, kind)
assertEquals(destination, resolveUpstreamDestination(destination, true, libraries))
}
}
}

View file

@ -15,6 +15,7 @@ import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import org.json.JSONObject
import org.junit.Before
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@ -43,14 +44,17 @@ import java.util.Locale
class TitleCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var fixture: TitleFixture
private var previousLocale = Locale.getDefault()
@Before fun fullscreen() {
previousLocale = Locale.getDefault()
Locale.setDefault(Locale.US)
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
}
@After fun restoreLocale() { Locale.setDefault(previousLocale) }
private fun capture(number: Int, title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }, configure: (TitleState, ShellState) -> Unit = { _, _ -> }) {
val data = transform(fixture.title(title, person))
@ -75,7 +79,9 @@ class TitleCaptureTest {
if (data.item.backdrop == null || data.item.isPosterBackdrop) data.item.poster?.let { add(Triple(it, 420, 630)) }
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
data.episodes.forEach { item -> (item.backdrop ?: item.poster)?.let { add(Triple(it, 416, 234)) } }
data.episodes.forEach { item ->
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, 416, 234)) }
}
}
return requests.distinctBy { it.first }.associate { (path, width, height) ->
val bitmap = rule.activity.assets.open(path.removePrefix("file:///android_asset/")).use { BitmapFactory.decodeStream(it)!! }
@ -89,7 +95,7 @@ class TitleCaptureTest {
private fun save(number: Int) {
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory")).apply { mkdirs() }
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "st-${number.toString().padStart(3, '0')}.png").absolutePath)
}

View file

@ -7,6 +7,9 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performMouseInput
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
@ -184,4 +187,68 @@ class TitleKeysTest {
assertEquals(TitleSheet.Version, state.sheet)
assertTrue(effects.none { it is TitleAction.Version })
}
@Test fun episodeWatchedToggleKeepsItsIdentityAndDoesNotAskAboutTheSeries() {
val episode = requireNotNull(fixture.title("Andor", "justin").playTarget)
render(episode.id)
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "watched" }) }
press(Key.DirectionCenter)
assertNull(state.sheet)
val change = effects.single() as TitleAction.Watched
assertEquals(episode.id, change.item.id)
assertEquals(!episode.watched, change.watched)
}
@Test fun aPendingSeasonSkipsItsEpisodeSkeletons() {
render("Andor")
rule.runOnIdle { state.showSeasonLoading() }
press(Key.DirectionDown); press(Key.DirectionDown)
assertEquals("cast", state.row)
rule.runOnIdle { state.restoreSeason() }
press(Key.DirectionUp)
assertEquals("episodes", state.row)
}
@Test fun aFailedOlderVersionWriteCannotUndoTheNewerChoice() {
render()
press(Key.DirectionRight); press(Key.DirectionCenter); press(Key.DirectionDown); press(Key.DirectionCenter)
val first = effects.last() as TitleAction.Version
press(Key.DirectionCenter); press(Key.DirectionUp); press(Key.DirectionCenter)
val second = effects.last() as TitleAction.Version
rule.runOnIdle { state.rejectPlayback(first); state.confirmPlayback(second) }
assertEquals("v1", state.versionId)
press(Key.DirectionCenter); press(Key.DirectionDown); press(Key.DirectionCenter)
val failed = effects.last() as TitleAction.Version
rule.runOnIdle { state.rejectPlayback(failed) }
assertEquals("v1", state.versionId)
}
@Test fun aNewRowUsesTheNearestHorizontalCardBeforeItsOwnRememberedCard() {
render()
repeat(4) { press(Key.DirectionRight) }
val x = rule.onNodeWithTag("title-option-trailer").fetchSemanticsNode().boundsInRoot.center.x
val cast = state.data.cast.take(WolfskinTokens.Card.personSlots)
val nearest = cast.indices.minBy { index ->
kotlin.math.abs(rule.onNodeWithTag("title-cast-${cast[index].id}").fetchSemanticsNode().boundsInRoot.center.x - x)
}
press(Key.DirectionDown)
assertEquals(nearest, state.column)
press(Key.DirectionRight)
val remembered = state.column
press(Key.DirectionUp); press(Key.DirectionDown)
assertEquals(remembered, state.column)
}
@Test fun wheelThenRemoteLandsOnTheFirstVisibleFocusableRow() {
render()
rule.onNodeWithTag("title-page").performMouseInput { scroll(800f) }
val expected = rule.runOnIdle {
shell.view.listState.layoutInfo.visibleItemsInfo.first {
it.key in state.rows && it.offset + it.size > WolfskinTokens.Layout.pinnedLine.value * 2
}.key as String
}
assertNotEquals("header", expected)
press(Key.DirectionDown)
assertEquals(expected, state.row)
}
}

View file

@ -29,7 +29,6 @@ import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
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.Assert.*
import org.junit.Before
import org.junit.Rule
@ -71,7 +70,7 @@ class TitleReadFacadeTest {
every { repository.currentUser } returns null
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
val context = mockk<Context> { every { filesDir } returns directory.root }
facade = TitleReadFacade(api, mockk<ImageUrlService>(), playback, repository, mockk<FavoriteWatchManager>(), context)
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
}
private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE) = BaseItemDto(
@ -89,7 +88,7 @@ class TitleReadFacadeTest {
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
val saved = facade.readSaved(firstId, preferences)!!
val screen = TitleState(saved, ShellPerson("justin", "Justin", "J", false)) {}
assertEquals("mkv".uppercase(), saved.versions.single().container)
assertEquals("MKV", saved.versions.single().container)
assertEquals(3, screen.audio?.index)
assertEquals(listOf(8), saved.versions.single().subtitles.map { it.index })
assertEquals(TitleConnection.SignedOut, saved.connection)
@ -109,6 +108,19 @@ class TitleReadFacadeTest {
assertEquals("A real file", facade.readSaved(firstId, preferences)?.item?.name)
}
@Test fun personBiographyAndContinuousCreditsRemainReadableFromDiskWithoutAService() = runTest {
val actor = BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor", overview = "Actual biography")
coEvery { library.getItem(firstId) } returns successResponse(actor)
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(file(secondId)))
facade.readPerson(firstId, 0)
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
coEvery { items.getItems(any<GetItemsRequest>()) } throws java.io.IOException("offline")
val saved = facade.readPersonSaved(firstId)!!
assertEquals("Actual biography", saved.person.biography)
assertEquals(secondId.toString(), saved.credits.single().id)
assertEquals("A real file", saved.credits.single().name)
}
@Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest {
val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9)
val other = file(secondId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 2, indexNumber = 1)
@ -124,5 +136,6 @@ class TitleReadFacadeTest {
assertEquals(firstId.toString(), data.watchTarget.id)
assertEquals(listOf(secondId.toString()), data.episodes.map { it.id })
assertEquals(listOf(2), data.seasonNumbers)
assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season)
}
}