Record native title metrics and reuse SDK fixtures

This commit is contained in:
justinzeus 2026-11-05 17:40:55 +01:00
parent 0ca13e684f
commit fc7eb94cc1
4 changed files with 36 additions and 14 deletions

View file

@ -12,6 +12,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import com.github.takahirom.roborazzi.captureRoboImage
import org.json.JSONObject
import org.junit.Before
@ -97,6 +102,19 @@ class TitleCaptureTest {
rule.waitForIdle()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "st-${number.toString().padStart(3, '0')}.png").absolutePath)
val heading = rule.onAllNodesWithTag("title-heading").fetchSemanticsNodes().singleOrNull()
if (heading != null) {
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithTag("title-heading").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
val layout = layouts.single()
val metrics = JSONObject().put("title", layout.layoutInput.text.text).put("lineCount", layout.lineCount)
.put("lineWidthsPx", org.json.JSONArray(List(layout.lineCount) { layout.getLineRight(it) - layout.getLineLeft(it) }))
.put("ellipsized", org.json.JSONArray(List(layout.lineCount) { layout.isLineEllipsized(it) }))
.put("maxWidthPx", layout.layoutInput.constraints.maxWidth).put("fontSizeSp", layout.layoutInput.style.fontSize.value)
.put("requestedWeight", layout.layoutInput.style.fontWeight?.weight)
.put("boundsPx", org.json.JSONArray(listOf(heading.boundsInRoot.left, heading.boundsInRoot.top, heading.boundsInRoot.right, heading.boundsInRoot.bottom)))
File(directory, "st-${number.toString().padStart(3, '0')}-layout.json").writeText(metrics.toString(2))
}
}
private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) }
@ -113,7 +131,7 @@ class TitleCaptureTest {
@Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") }
@Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") }
@Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." }
@Test fun st049_watched() = capture(49) { state, _ -> state.restoreWatched(true); option(state, "watched") }
@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) }

View file

@ -24,8 +24,7 @@ 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.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import com.github.damontecres.wholphin.test.TestTracks
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
@ -73,14 +72,19 @@ class TitleReadFacadeTest {
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
}
private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE) = BaseItemDto(
id = id, name = "A real file", type = kind, runTimeTicks = 6_000_000_000,
mediaSources = listOf(MediaSourceInfo(id = sourceId, name = "Original", container = "mkv", mediaStreams = listOf(
MediaStream(index = 0, type = MediaStreamType.VIDEO, displayTitle = "1080p"),
MediaStream(index = 3, type = MediaStreamType.AUDIO, displayTitle = "English", isDefault = true),
MediaStream(index = 8, type = MediaStreamType.SUBTITLE, displayTitle = "Dutch"),
))),
)
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 ->
when (stream.type) {
MediaStreamType.VIDEO -> stream.copy(index = 0, displayTitle = "1080p")
MediaStreamType.AUDIO -> stream.copy(index = 3, displayTitle = "English", isDefault = true)
MediaStreamType.SUBTITLE -> stream.copy(index = 8, displayTitle = "Dutch")
else -> error("Unexpected track in the SDK fixture builder")
}
}
return BaseItemDto(id = id, name = "A real file", type = kind, runTimeTicks = 6_000_000_000,
mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams)))
}
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))

View file

@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.design
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.platform.testTag
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -45,7 +46,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
if (state.data.hidden) append(" · Hidden from your picks")
}, color = C.accentText, style = D.dLine)
}
Text(item.name, color = C.fg, style = WolfskinTokens.Type.displayDetail, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = D.titleBottom))
Text(item.name, color = C.fg, style = WolfskinTokens.Type.displayDetail, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = D.titleBottom).testTag("title-heading"))
Row(Modifier.heightIn(min = L.iconCaptionLine + D.factsBottom).padding(bottom = D.factsBottom), horizontalArrangement = Arrangement.spacedBy(D.factsGap), verticalAlignment = Alignment.CenterVertically) {
item.year?.let { Text(it.toString(), color = C.muted, style = WolfskinTokens.Type.body) }
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = WolfskinTokens.Type.body)

View file

@ -1,6 +1,5 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween
@ -130,7 +129,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
if (item.held && !tile) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) {
TitleGlyph("check", C.onFocus, Modifier.size(L.buttonIcon))
}
AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) {
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) {
TitleRatingPlate(item.ratings, Modifier.fillMaxWidth())
}
}