Merge the TV round two integration branch
This commit is contained in:
commit
98dd54e03b
22 changed files with 515 additions and 92 deletions
|
|
@ -40,6 +40,7 @@
|
|||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:hasFragileUserData="true"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ class ConductarrTitlePagesTest {
|
|||
assertEquals(TitleConnection.SignedOut, revoked.connection)
|
||||
assertTrue(revoked.item.ratings.isEmpty())
|
||||
assertTrue(revoked.playTarget!!.ratings.isEmpty())
|
||||
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" })
|
||||
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.menuOptions.any { it.id == "add" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ class ConductarrTitleSurfaceTest {
|
|||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath)
|
||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) }
|
||||
rule.mainClock.advanceTimeBy(300)
|
||||
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
|
||||
rule.onNodeWithText("Request for Mindel").assertIsDisplayed()
|
||||
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
|
||||
rule.waitUntil(30_000) {
|
||||
|
|
@ -97,7 +100,8 @@ class ConductarrTitleSurfaceTest {
|
|||
viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable
|
||||
}
|
||||
assertTrue(viewModel.title!!.data.item.ratings.isEmpty())
|
||||
assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") })
|
||||
assertFalse(viewModel.title!!.options.any { it.id == "request" })
|
||||
assertFalse(viewModel.title!!.menuOptions.any { it.id == "add" })
|
||||
} finally { viewModel.viewModelScope.cancel() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ class PersonPickerSessionTest {
|
|||
private fun awaitLoaded(user: JellyfinUser) {
|
||||
try {
|
||||
rule.waitUntil(10_000) {
|
||||
// Setup navigation writes Compose state asynchronously; publish it before pumping replacement composition.
|
||||
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||
models.lastOrNull()?.signedInPerson == user.name!!.lowercase() &&
|
||||
models.last().conductarr.identity.value?.signedIn == true &&
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class TitleCaptureTest {
|
|||
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
||||
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
||||
data.episodes.forEach { item ->
|
||||
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) }
|
||||
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt())) }
|
||||
}
|
||||
}
|
||||
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
||||
|
|
@ -170,7 +170,10 @@ class TitleCaptureTest {
|
|||
@Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") }
|
||||
@Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") }
|
||||
@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 st047_mediaInfo() = capture(47) { state, shell ->
|
||||
open(state, shell, "menu")
|
||||
state.clickSheet(0)
|
||||
}
|
||||
@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, 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") }
|
||||
|
|
@ -216,4 +219,10 @@ class TitleCaptureTest {
|
|||
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
||||
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
|
||||
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
|
||||
@Test fun round2Menu() = capture(120) { state, shell -> open(state, shell, "menu") }
|
||||
@Test fun round2MussoliniResume() = capture(121, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) })
|
||||
@Test fun round2Episodes() = capture(122, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) { state, _ -> state.focus("episodes", 0) }
|
||||
@Test fun round2NotHeldSeries() = capture(123, "90 Day Fiancé: Before the 90 Days")
|
||||
@Test fun round2HeldPosterFallback() = capture(124, "The Girl Who Leapt Through Time", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
|
||||
@Test fun round2NotHeldShortOverview() = capture(125, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,6 @@ import androidx.compose.ui.test.onNodeWithText
|
|||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
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
|
||||
|
|
@ -36,7 +33,6 @@ 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)
|
||||
|
|
@ -284,15 +280,6 @@ 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) })
|
||||
|
|
@ -315,26 +302,6 @@ class TitleKeysTest {
|
|||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,274 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import java.io.File
|
||||
|
||||
/** Native title interactions keep secondary actions, focus and section geometry intact. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class TitleRound2Test {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val effects = mutableListOf<TitleAction>()
|
||||
private lateinit var state: TitleState
|
||||
private lateinit var shell: ShellState
|
||||
|
||||
private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
||||
private fun render(title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }) {
|
||||
val fixture = fixture()
|
||||
shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/test") }
|
||||
state = TitleState(transform(fixture.title(title, person)), shell.person, effects::add)
|
||||
rule.setContent { WolfskinTheme {
|
||||
BackHandler { state.back(shell) }
|
||||
TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp))
|
||||
} }
|
||||
}
|
||||
private fun press(key: Key) = rule.onRoot().performKeyInput { pressKey(key) }
|
||||
private fun menu() {
|
||||
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "menu" }) }
|
||||
press(Key.DirectionCenter)
|
||||
rule.mainClock.advanceTimeBy(300)
|
||||
rule.waitForIdle()
|
||||
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun menuBackRestoresItsButtonAndKeepsMediaInfoAndRequestReachable() {
|
||||
render()
|
||||
menu()
|
||||
val opener = state.column
|
||||
press(Key.Back)
|
||||
assertNull(state.sheet)
|
||||
assertEquals(opener, state.column)
|
||||
assertFalse(shell.navFocused)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(TitleSheet.Menu, state.sheet)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(TitleSheet.MediaInfo, state.sheet)
|
||||
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
||||
press(Key.Back)
|
||||
assertEquals(TitleSheet.Menu, state.sheet)
|
||||
press(Key.DirectionDown)
|
||||
press(Key.DirectionCenter)
|
||||
assertNull(state.sheet)
|
||||
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
|
||||
assertEquals(opener, state.column)
|
||||
}
|
||||
|
||||
@Test fun notHeldMovieKeepsRequestAndHideOutsideTheMenu() {
|
||||
render("The Amazing Spider-Man")
|
||||
rule.onNodeWithTag("title-option-request").assertIsDisplayed()
|
||||
rule.onNodeWithTag("title-option-hide").assertIsDisplayed()
|
||||
menu()
|
||||
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
|
||||
}
|
||||
|
||||
@Test fun movieCastStacksAfterActionsInsteadOfAReservedHeaderHeight() {
|
||||
render("The Amazing Spider-Man")
|
||||
val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||
val cast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
|
||||
val gap = cast.top - actions.bottom
|
||||
assertTrue("Cast follows the actions without the old fixed header reservation", gap > 0 && cast.top < WolfskinTokens.Layout.row_dhead.value * 2)
|
||||
rule.runOnIdle { state.update(state.data.copy(requestedStage = "Searching", requestActor = "Justin")) }
|
||||
val requestedActions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||
val requestedCast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(requestedActions.bottom < requestedCast.top)
|
||||
assertTrue("A readiness line cannot move Cast backwards or recreate the old reservation", requestedCast.top >= cast.top && requestedCast.top < WolfskinTokens.Layout.row_dhead.value * 2)
|
||||
}
|
||||
|
||||
@Test fun mussoliniResumeHasOnlyFactsRatingsAndNoRedundantSeasonsAction() {
|
||||
render("episode-episode-1-2025-s1e1", "shared") { data -> data.copy(episodes = data.episodes.distinctBy { it.id }) }
|
||||
assertTrue(state.data.playTarget!!.resumeSeconds > 0)
|
||||
rule.onNodeWithTag("title-progress").assertIsDisplayed()
|
||||
rule.onAllNodesWithContentDescription("rtCritics", useUnmergedTree = true).assertCountEquals(1)
|
||||
rule.onNodeWithText("Ep", useUnmergedTree = true).assertDoesNotExist()
|
||||
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
|
||||
press(Key.DirectionDown)
|
||||
assertEquals("seasons", state.row)
|
||||
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun seriesKeepsItsSeasonsShortcutAndSecondaryRequestUsesTheMenu() {
|
||||
render("Andor")
|
||||
rule.onNodeWithTag("title-option-seasons").assertIsDisplayed()
|
||||
menu()
|
||||
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
||||
press(Key.Back)
|
||||
press(Key.DirectionRight)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals("seasons", state.row)
|
||||
}
|
||||
|
||||
@Test fun episodeSkeletonAndReadySlotsMatchWhileMetadataFitsOneLine() {
|
||||
render("Andor") { it.copy(episodesStatus = TitleReadStatus.Loading) }
|
||||
rule.runOnIdle { shell.view.listState.requestScrollToItem(2) }
|
||||
rule.waitForIdle()
|
||||
val skeleton = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
|
||||
rule.runOnIdle { state.update(state.data.copy(episodesStatus = TitleReadStatus.Ready)) }
|
||||
val ready = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(skeleton, ready)
|
||||
assertEquals(448f, ready.width, 0.1f)
|
||||
assertEquals("Wider cards retain the approved taller episode art", 280f, ready.height, 0.1f)
|
||||
rule.runOnIdle { state.focus("episodes", 0) }
|
||||
val episode = state.data.episodes.first()
|
||||
rule.onNodeWithText(episode.episodeLabel + " · " + episode.runtime).assertIsDisplayed()
|
||||
rule.onNode(hasText(episode.seriesName!!) and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test fun revokedRequestsDisappearFromMenuWithoutRemovingMediaInfo() {
|
||||
render()
|
||||
menu()
|
||||
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
|
||||
rule.onNodeWithText("Add for someone").assertDoesNotExist()
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(TitleSheet.MediaInfo, state.sheet)
|
||||
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun shortOverviewMoviesKeepCastBelowThePosterForHeldAndNotAddedTitles() {
|
||||
render("The Amazing Spider-Man") { it.copy(item = it.item.copy(overview = "Short overview.", backdrop = null, isPosterBackdrop = true)) }
|
||||
for (held in listOf(false, true)) {
|
||||
rule.runOnIdle { state.update(state.data.copy(item = state.data.item.copy(held = held))) }
|
||||
val poster = rule.onNodeWithTag("title-poster-backdrop").fetchSemanticsNode().boundsInRoot
|
||||
val firstActor = rule.onNodeWithTag("title-cast-${state.data.cast.first().id}").fetchSemanticsNode().boundsInRoot
|
||||
assertTrue("Cast clears the poster on held=$held: $firstActor vs $poster", firstActor.top >= poster.bottom + 20f - 0.5f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun revokingTheOnlyMenuActionClosesAnOtherwiseEmptyMenu() {
|
||||
render("The Amazing Spider-Man")
|
||||
menu()
|
||||
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
|
||||
assertNull(state.sheet)
|
||||
rule.mainClock.advanceTimeBy(300)
|
||||
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test fun mediaInfoKeepsTableTextSeparatedInsteadOfAccumulatingOverlap() {
|
||||
render()
|
||||
menu()
|
||||
press(Key.DirectionCenter)
|
||||
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
||||
rule.onNodeWithText("Dutch", useUnmergedTree = true).assertIsDisplayed()
|
||||
val text = rule.onAllNodes(SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsActions.GetTextLayoutResult) and hasAnyAncestor(hasTestTag("title-sheet")), useUnmergedTree = true)
|
||||
.fetchSemanticsNodes().map { it.boundsInRoot }.filter { it.width > 0 && it.height > 0 }
|
||||
text.forEachIndexed { index, first -> text.drop(index + 1).forEach { second ->
|
||||
if (minOf(first.right, second.right) > maxOf(first.left, second.left)) {
|
||||
assertTrue("Media info text must never collide vertically: $first vs $second", minOf(first.bottom, second.bottom) <= maxOf(first.top, second.top))
|
||||
}
|
||||
} }
|
||||
}
|
||||
|
||||
@Test fun menuToMediaInfoNeverDoubleDimsOrPopsAndBackKeepsOneScrim() {
|
||||
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
|
||||
menu()
|
||||
rule.mainClock.autoAdvance = false
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-info-motion").apply { mkdirs() }
|
||||
rule.runOnIdle { state.clickSheet(0) }
|
||||
var waited = 0
|
||||
while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.waitForIdle()
|
||||
waited += 16
|
||||
}
|
||||
val records = mutableListOf<String>()
|
||||
fun frame(phase: String, index: Int): Pair<Int, Int> {
|
||||
val path = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||
rule.onRoot().captureRoboImage(path.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(path.absolutePath)
|
||||
fun brightness(x: Int, y: Int): Int {
|
||||
val pixel = bitmap.getPixel(x, y)
|
||||
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||
}
|
||||
return try {
|
||||
(brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") }
|
||||
} finally { bitmap.recycle() }
|
||||
}
|
||||
val info = List(14) { index ->
|
||||
rule.waitForIdle()
|
||||
frame("info", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
|
||||
}
|
||||
info.forEach { assertEquals("No extra scrim or disappearing overlay may darken or pop over Media info", info.last(), it) }
|
||||
rule.runOnIdle { state.back(shell) }
|
||||
repeat(14) { index ->
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
rule.waitForIdle()
|
||||
assertEquals("Returning to Menu keeps exactly one scrim", info.last().second, frame("back", index).second)
|
||||
}
|
||||
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertExists()
|
||||
File(directory, "samples.csv").writeText("phase,timeMs,sheetBrightness,leftBrightness\n" + records.joinToString("\n"))
|
||||
rule.mainClock.autoAdvance = true
|
||||
}
|
||||
|
||||
@Test fun menuFadeFramesStayMonotonicAndNeverTranslate() {
|
||||
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
|
||||
rule.mainClock.autoAdvance = false
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-motion").apply { mkdirs() }
|
||||
rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "menu" }) }
|
||||
var waited = 0
|
||||
while (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.waitForIdle()
|
||||
waited += 16
|
||||
}
|
||||
val bounds = rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
val samples = mutableListOf<String>()
|
||||
fun frame(phase: String, index: Int): Int {
|
||||
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val image = BitmapFactory.decodeFile(file.absolutePath)
|
||||
val brightness = try {
|
||||
val pixel = image.getPixel((bounds.left + 40).toInt(), (bounds.top + 6).toInt())
|
||||
android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||
} finally { image.recycle() }
|
||||
samples.add("$phase,${index * 16},$brightness,${bounds.left},${bounds.top}")
|
||||
return brightness
|
||||
}
|
||||
fun assertMonotonic(values: List<Int>) {
|
||||
assertNotEquals("Fade must visibly change the menu pixel", values.first(), values.last())
|
||||
val direction = if (values.last() > values.first()) 1 else -1
|
||||
values.zipWithNext().forEach { (before, after) ->
|
||||
assertTrue("Menu alpha must not flicker or snap back: $values", (after - before) * direction >= -1)
|
||||
}
|
||||
}
|
||||
val entering = List(14) { index ->
|
||||
rule.waitForIdle()
|
||||
assertEquals(bounds, rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot)
|
||||
frame("open", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
|
||||
}
|
||||
assertMonotonic(entering)
|
||||
rule.runOnIdle { state.back(shell) }
|
||||
val leaving = mutableListOf<Int>()
|
||||
repeat(9) { index ->
|
||||
rule.waitForIdle()
|
||||
if (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()) leaving.add(frame("close", index))
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
}
|
||||
assertMonotonic(leaving)
|
||||
rule.onNodeWithTag("title-menu").assertDoesNotExist()
|
||||
File(directory, "samples.csv").writeText("phase,timeMs,brightness,leftPx,topPx\n" + samples.joinToString("\n"))
|
||||
rule.mainClock.autoAdvance = true
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(TitleSheet.Menu, state.sheet)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue