Preserve native title line boxes and async focus

This commit is contained in:
justinzeus 2026-11-05 20:26:43 +01:00
parent a8951827ba
commit 43ae716157
9 changed files with 187 additions and 32 deletions

View file

@ -0,0 +1,88 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key
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.lifecycle.viewModelScope
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleItem
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.TitleReadStatus
import org.justinvisser.wolfskin.design.TitleSheet
import org.justinvisser.wolfskin.design.TitleTrack
import org.justinvisser.wolfskin.design.TitleVersion
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.UUID
/** A delayed service reply exercises the production destination, view-model and Android focus transfer. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class TitleAsyncFocusTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var viewModel: WolfskinTitleViewModel
@After fun dispose() {
if (::viewModel.isInitialized) viewModel.viewModelScope.cancel()
}
@Test fun loadingToReadyRestoresRealKeyFocusAndSheetReturn() {
val id = UUID.fromString("00000000-0000-0000-0000-000000000012")
val ready = CompletableDeferred<TitlePageData>()
val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(id, any()) } returns null
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true))
val person = ShellPerson("justin", "Justin", "J", false)
val route = titleRoute(id.toString())
val shell = ShellState(listOf(person), person.key).apply {
pickerVisible = false
pickerLaunch = false
enterDestination(route)
}
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } }
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
rule.waitForIdle()
assertTrue(shell.navFocused)
press(Key.DirectionDown)
assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused)
val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList())
rule.runOnIdle {
ready.complete(TitlePageData(TitleItem(id.toString(), "A held film", "movie", held = true), versions = listOf(source, source.copy(id = "second", name = "Alternate"))))
}
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Ready && !shell.navFocused }
press(Key.DirectionRight)
val state = requireNotNull(viewModel.title)
assertEquals("version", state.options[state.column].id)
press(Key.DirectionCenter)
assertEquals(TitleSheet.Version, state.sheet)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertNull(state.sheet)
press(Key.DirectionRight)
assertEquals("tracks", state.options[state.column].id)
assertFalse(shell.navFocused)
}
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
}

View file

@ -17,6 +17,8 @@ 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 androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.graphics.toArgb
import com.github.takahirom.roborazzi.captureRoboImage
import org.json.JSONObject
import org.junit.Before
@ -115,6 +117,30 @@ class TitleCaptureTest {
.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))
}
recordTextMetrics(directory, number)
}
private fun recordTextMetrics(directory: File, number: Int) {
val elements = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true)
val records = org.json.JSONArray()
elements.fetchSemanticsNodes().forEachIndexed { index, node ->
if (node.boundsInRoot.width > 0 && node.boundsInRoot.height > 0) {
val layouts = mutableListOf<TextLayoutResult>()
elements[index].performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
val layout = layouts.single()
records.put(JSONObject().put("text", layout.layoutInput.text.text)
.put("fontSizeSp", layout.layoutInput.style.fontSize.value)
.put("weight", layout.layoutInput.style.fontWeight?.weight)
.put("colourArgb", Integer.toUnsignedString(layout.layoutInput.style.color.toArgb(), 16))
.put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL)
.put("lineCount", layout.lineCount)
.put("boundsPx", org.json.JSONArray(listOf(node.boundsInRoot.left, node.boundsInRoot.top, node.boundsInRoot.right, node.boundsInRoot.bottom)))
.put("spans", org.json.JSONArray(layout.layoutInput.text.spanStyles.map { span ->
JSONObject().put("start", span.start).put("end", span.end).put("weight", span.item.fontWeight?.weight)
})))
}
}
File(directory, "st-${number.toString().padStart(3, '0')}-text.json").writeText(records.toString(2))
}
private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) }

View file

@ -33,6 +33,7 @@ import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
@RunWith(RobolectricTestRunner::class)
@ -289,4 +290,15 @@ class TitleKeysTest {
val genre = rule.onNodeWithText("Drama", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() {
render()
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) }
press(Key.DirectionCenter)
val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
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)
}
}