From fc7eb94cc1a94e0ca258f90e48b2086b4cc2de8f Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 17:40:55 +0100 Subject: [PATCH] Record native title metrics and reuse SDK fixtures --- .../wolfskin/tv/TitleCaptureTest.kt | 20 +++++++++++++++- .../wolfskin/tv/TitleReadFacadeTest.kt | 24 +++++++++++-------- .../wolfskin/design/TitleHeader.kt | 3 ++- .../wolfskin/design/TitleShelf.kt | 3 +-- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt index 7d98a85a..5a3d73b6 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt @@ -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() + 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) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt index 083a253e..20157a52 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -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(relaxed = true), playback, repository, mockk(), 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)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt index 401352fd..5adef0ff 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt @@ -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) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index d0e88039..a4cd14c8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -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()) } }