Match title CSS geometry and fix modal focus and subtitle choices

This commit is contained in:
justinzeus 2026-11-05 21:38:46 +01:00
parent e243894a20
commit 2fdd459037
21 changed files with 297 additions and 74 deletions

View file

@ -84,5 +84,43 @@ class TitleAsyncFocusTest {
assertFalse(shell.navFocused)
}
@Test fun loadingPersonFocusesItsFirstCreditBeforeRealRemoteNavigation() {
val id = UUID.fromString("00000000-0000-0000-0000-000000000013")
val reply = CompletableDeferred<org.justinvisser.wolfskin.tv.read.PersonRead>()
val facade = mockk<TitleReadFacade>()
coEvery { facade.readPersonSaved(id) } returns null
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true))
val viewer = ShellPerson("justin", "Justin", "J", false)
val route = personRoute(id.toString())
val shell = ShellState(listOf(viewer), viewer.key).apply {
pickerVisible = false
pickerLaunch = false
enterDestination(route)
}
rule.setContent { WolfskinTheme {
TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel)
} }
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
rule.waitForIdle()
assertTrue(shell.navFocused)
press(Key.DirectionDown)
assertTrue(shell.navFocused)
rule.runOnIdle {
reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead(
org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"),
listOf(TitleItem("first-credit", "First credit", "movie"), TitleItem("second-credit", "Second credit", "movie")),
))
}
rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable }
assertNotNull("A real person reply must populate the performer destination", viewModel.person)
rule.waitForIdle()
assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused)
assertEquals(0, viewModel.person!!.index)
press(Key.DirectionRight)
assertEquals(1, viewModel.person!!.index)
assertFalse(shell.navFocused)
}
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
}

View file

@ -118,6 +118,7 @@ class TitleCaptureTest {
File(directory, "st-${number.toString().padStart(3, '0')}-layout.json").writeText(metrics.toString(2))
}
recordTextMetrics(directory, number)
recordElements(directory, number)
}
private fun recordTextMetrics(directory: File, number: Int) {
@ -143,6 +144,20 @@ class TitleCaptureTest {
File(directory, "st-${number.toString().padStart(3, '0')}-text.json").writeText(records.toString(2))
}
private fun recordElements(directory: File, number: Int) {
val tag = androidx.compose.ui.semantics.SemanticsProperties.TestTag
val description = androidx.compose.ui.semantics.SemanticsProperties.ContentDescription
val matcher = SemanticsMatcher.keyIsDefined(tag) or SemanticsMatcher.keyIsDefined(description)
val records = org.json.JSONArray()
rule.onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
records.put(JSONObject()
.put("tag", if (node.config.contains(tag)) node.config[tag] else JSONObject.NULL)
.put("description", if (node.config.contains(description)) org.json.JSONArray(node.config[description]) else JSONObject.NULL)
.put("boundsPx", org.json.JSONArray(listOf(node.boundsInRoot.left, node.boundsInRoot.top, node.boundsInRoot.right, node.boundsInRoot.bottom))))
}
File(directory, "st-${number.toString().padStart(3, '0')}-elements.json").writeText(records.toString(2))
}
private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) }
private fun open(state: TitleState, shell: ShellState, id: String) {
option(state, id)

View file

@ -300,5 +300,46 @@ class TitleKeysTest {
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f)
val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f)
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun filmHeaderKeepsItsFixedCssStack() {
render()
assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f)
assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f)
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun episodeProgressReclaimsItsNegativeMarginBeforeTheActionRow() {
render("Andor")
assertEquals(603f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
}
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
render(transform = { data ->
val source = data.versions.first().copy(
audio = List(3) { org.justinvisser.wolfskin.design.TitleTrack(it + 3, "Audio ${it + 1}") },
subtitles = List(15) { org.justinvisser.wolfskin.design.TitleTrack(it + 20, "Subtitle ${it + 1}") },
)
data.copy(versions = listOf(source))
})
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "tracks" }) }
press(Key.DirectionCenter)
val bottom = rule.onRoot().fetchSemanticsNode().boundsInRoot.bottom
val labels = (1..3).map { "Audio $it" } + "Off" + (1..15).map { "Subtitle $it" } + "Done"
labels.forEachIndexed { index, label ->
if (index > 0) press(Key.DirectionDown)
rule.waitForIdle()
val bounds = rule.onNodeWithText(label, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertTrue("$label must have visible height: $bounds", bounds.height > 20)
assertTrue("$label stays above the viewport bottom: $bounds / $bottom", bounds.bottom <= bottom)
assertTrue("$label stays below the viewport top: $bounds", bounds.top >= 0)
}
press(Key.DirectionCenter)
assertNull(state.sheet)
assertFalse(shell.navFocused)
}
}

View file

@ -166,4 +166,15 @@ class TitleReadFacadeTest {
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")) }
}
@Test fun subtitleOffDisablesPlaybackRatherThanRestoringThePreferredLanguage() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
coEvery { playback.saveTrackSelection(any(), any(), any(), MediaStreamType.SUBTITLE) } returns
com.github.damontecres.wholphin.data.model.ItemPlayback(userId = 1, itemId = firstId)
val data = facade.read(firstId, null, preferences)
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Track(data.watchTarget, data.versions.single(), audio = null, subtitle = -1))
coVerify(exactly = 1) { playback.saveTrackSelection(any(), any(), com.github.damontecres.wholphin.data.model.TrackIndex.DISABLED, MediaStreamType.SUBTITLE) }
coVerify(exactly = 0) { playback.saveTrackSelection(any(), any(), com.github.damontecres.wholphin.data.model.TrackIndex.UNSPECIFIED, MediaStreamType.SUBTITLE) }
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Track(data.watchTarget, data.versions.single(), audio = null, subtitle = 8))
coVerify(exactly = 1) { playback.saveTrackSelection(any(), any(), 8, MediaStreamType.SUBTITLE) }
}
}