Reuse shared artwork and correct title focus edges

This commit is contained in:
justinzeus 2026-11-05 19:58:36 +01:00
parent 3bfb8cd779
commit a8951827ba
41 changed files with 581 additions and 438 deletions

View file

@ -134,7 +134,11 @@ class TitleCaptureTest {
@Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") }
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
@Test fun st051_episodeRestart() = capture(51, "episode-sex-and-the-country-2001-s4e9", "mindel") { state, _ -> option(state, "restart") }
@Test fun st052_filmCast() = capture(52) { state, _ -> state.focus("cast", 0) }
@Test fun st052_filmCast() = capture(52) { state, shell ->
state.focus("cast", 0)
state.key(ShellKey.Right, true, 0, shell)
state.key(ShellKey.Right, false, 0, shell)
}
@Test fun st053_seriesCast() = capture(53, "Andor") { state, _ -> state.focus("cast", 0) }
@Test fun st054_initialsCast() = capture(54, "Spirited Away", "shared") { state, _ -> state.focus("cast", 0) }
@Test fun st055_castLoading() = capture(55, transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Loading) })
@ -162,7 +166,9 @@ class TitleCaptureTest {
@Test fun st067_trailerFallback() = capture(67, "The Amazing Spider-Man")
@Test fun st068_requested() = capture(68, "The Amazing Spider-Man", transform = { it.copy(requestedStage = "Searching", requestActor = "Justin") })
@Test fun st069_missingArt() = capture(69, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(poster = null, backdrop = null)) })
@Test fun st070_offline() = capture(70, "The Amazing Spider-Man", transform = { it.copy(connection = TitleConnection.Offline) })
@Test fun st070_offline() = capture(70, "The Amazing Spider-Man", transform = {
it.copy(connection = TitleConnection.Offline, savedAt = "20:42", item = it.item.copy(ratings = emptyList()))
})
@Test fun st104_relatedPlate() = capture(104, "The Girl Who Leapt Through Time") { state, _ -> state.focus("related", state.data.related.indexOfFirst { it.name == "INVINCIBLE" }.coerceAtLeast(0)) }
@Test fun st106_heldRatings() = capture(106, "American Gangster", "shared")
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")

View file

@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
@ -9,6 +10,10 @@ 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 androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.json.JSONArray
import org.json.JSONObject
@ -50,7 +55,10 @@ class TitleKeysTest {
private fun render(title: String = "Locke", transform: (org.justinvisser.wolfskin.design.TitlePageData) -> org.justinvisser.wolfskin.design.TitlePageData = { it }) {
shell = ShellState(fixture.people, "justin", shellEffects::add).apply { pickerVisible = false; pickerLaunch = false; enterDestination(titleRoute(title)) }
state = TitleState(transform(fixture.title(title, "justin")), shell.person, effects::add)
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
rule.setContent { WolfskinTheme {
BackHandler { state.back(shell) }
TitleSurface(state, shell)
} }
}
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
@ -251,4 +259,34 @@ class TitleKeysTest {
press(Key.DirectionDown)
assertEquals(expected, state.row)
}
@Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() {
render("Andor")
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithText("Add for someone", useUnmergedTree = true)
.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
val layout = layouts.single()
assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth)
assertEquals(1, layout.lineCount)
}
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
render(transform = { it.copy(status = TitleReadStatus.Loading) })
rule.onNodeWithText("Cast").assertDoesNotExist()
press(Key.DirectionDown)
assertTrue(shell.navFocused)
assertEquals("header", state.row)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertEquals(listOf(TitleAction.Pop), effects)
}
@Test fun unratedFactsDoNotReserveAnEmptyRatingsSlot() {
render("The Amazing Spider-Man", transform = {
it.copy(item = it.item.copy(ratings = emptyList(), genres = listOf("Drama"), runtimeSeconds = 0),
cast = emptyList(), related = emptyList())
})
val year = rule.onNodeWithText("2012", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val genre = rule.onNodeWithText("Drama", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
}
}

View file

@ -18,7 +18,7 @@ import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleConnection
import org.justinvisser.wolfskin.design.TitleItem
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.TitleRating
import org.justinvisser.wolfskin.design.LibraryRating
import org.justinvisser.wolfskin.design.TitleState
import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
@ -39,14 +39,14 @@ class TitleLocaleTest {
try {
Locale.setDefault(locale)
val person = ShellPerson("justin", "Justin", "J", false)
val item = TitleItem("movie:1", "Title", "movie", ratings = listOf(TitleRating("tmdb", 7.8, "tmdb")))
val item = TitleItem("movie:1", "Title", "movie", ratings = listOf(LibraryRating("tmdb", 7.8)))
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("title/movie:1") }
val state = TitleState(TitlePageData(item, related = listOf(item.copy(id = "movie:2")), connection = TitleConnection.SignedIn), person) {}
state.focus("related", 0)
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
rule.mainClock.advanceTimeBy(1000)
val layouts = mutableListOf<TextLayoutResult>()
rule.onAllNodesWithText(expected).onLast().performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) }
rule.onAllNodesWithText(expected, useUnmergedTree = true).onLast().performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) }
assertEquals(1, layouts.single().lineCount)
assertFalse(layouts.single().didOverflowWidth)
} finally { Locale.setDefault(previous) }

View file

@ -2,6 +2,7 @@ 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.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
@ -12,8 +13,11 @@ 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.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
@ -29,6 +33,7 @@ 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.junit.Assert.*
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -40,6 +45,7 @@ import org.justinvisser.wolfskin.design.TitleState
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.RuntimeEnvironment
import java.util.UUID
/** SDK replies stay in-process; disk reload and identity isolation exercise the real facade. */
@ -59,9 +65,15 @@ class TitleReadFacadeTest {
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000002")
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003")
private val seasonId = UUID.fromString("00000000-0000-0000-0000-000000000004")
private val sourceId = UUID.fromString("00000000-0000-0000-0000-000000000005").toString()
private val sourceId = UUID.fromString("00000000-0000-0000-0000-000000000005").toString().replace("-", "")
@Before fun setup() {
// BaseItem formats runtime using application resources; no Hilt/network application is started.
val app = mockk<WholphinApplication> {
every { resources } returns RuntimeEnvironment.getApplication().resources
}
mockkObject(WholphinApplication.Companion)
every { WholphinApplication.instance } returns app
every { api.userLibraryApi } returns library
every { api.itemsApi } returns items
every { api.tvShowsApi } returns shows
@ -72,6 +84,10 @@ class TitleReadFacadeTest {
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
}
@After fun teardown() {
unmockkObject(WholphinApplication.Companion)
}
private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE): BaseItemDto {
val source = TestTracks.Builder().addVideo().addAudio().addSubtitle().buildMediaSourceInfo()
val streams = source.mediaStreams.orEmpty().map { stream ->
@ -142,4 +158,12 @@ class TitleReadFacadeTest {
assertEquals(listOf(2), data.seasonNumbers)
assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season)
}
@Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
coEvery { playback.savePlayVersion(any(), any()) } returns null
val data = facade.read(firstId, null, preferences)
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Version(data.watchTarget, data.versions.single()))
coVerify(exactly = 1) { playback.savePlayVersion(firstId, UUID.fromString("00000000-0000-0000-0000-000000000005")) }
}
}