Implement option B card metadata and solid bottom edges
Some checks failed
review/code-claude Blocking: CardCaption focus-only caption invisible after first focus move in a shelf
review/design-claude Design review: 1 blocking (unframed skeleton caption bars); other differences are renderer/fixture artifacts
Some checks failed
review/code-claude Blocking: CardCaption focus-only caption invisible after first focus move in a shelf
review/design-claude Design review: 1 blocking (unframed skeleton caption bars); other differences are renderer/fixture artifacts
This commit is contained in:
parent
fdff85c8a1
commit
fe44a3c24f
27 changed files with 536 additions and 234 deletions
|
|
@ -0,0 +1,86 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Real white, dark and colourful art cannot leak through the card's opaque bottom edge. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class CardBottomPixelTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun realPosterBottomRowsStayOpaqueAtRestAndFocused() {
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
val focused = mutableStateOf(-1)
|
||||
val showArt = mutableStateOf(true)
|
||||
val state = mutableStateOf("unwatched")
|
||||
val names = listOf("annie-hall-1977", "locke-2014", "apocalypse-now-1979", "avatar-aang-the-last-airbender-2026")
|
||||
rule.setContent { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||
names.forEachIndexed { index, name ->
|
||||
LibraryPoster(LibraryTitle(name, name, "movie", poster = if (showArt.value) "/library/assets/movie-$name-poster.jpg" else null,
|
||||
played = state.value == "watched", progress = if (state.value == "resume") .43f else 0f),
|
||||
focused.value == index, false, false, Modifier.offset((48 + 148 * index).dp, 130.dp), ::fixtureArt)
|
||||
}
|
||||
}
|
||||
} }
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "card-pixels").apply { mkdirs() }
|
||||
for (watchState in listOf("unwatched", "watched", "resume")) for (selection in -1..3) {
|
||||
rule.runOnIdle { state.value = watchState; focused.value = selection; showArt.value = false }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val referenceFile = File(directory, "reference.png")
|
||||
rule.onRoot().captureRoboImage(referenceFile.absolutePath)
|
||||
val reference = BitmapFactory.decodeFile(referenceFile.absolutePath)
|
||||
rule.runOnIdle { showArt.value = true }
|
||||
rule.waitForIdle()
|
||||
val file = File(directory, "$watchState-focus-$selection.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
names.forEachIndexed { index, name ->
|
||||
val focus = selection == index
|
||||
val left = 96 + 296 * index
|
||||
val bottom = if (focus) 659 else 643
|
||||
val edgeLeft = if (focus) left - 10 else left
|
||||
val edgeRight = if (focus) left + 266 else left + 256
|
||||
// The reference supplies only rounded-corner/ring coverage, never poster colours.
|
||||
for (x in edgeLeft until edgeRight) assertEquals("$name $watchState focus=$focus full bottom row ($x,$bottom)",
|
||||
reference.getPixel(x, bottom), bitmap.getPixel(x, bottom))
|
||||
val expected = if (focus) WolfskinTokens.Color.accent.toArgb() else WolfskinTokens.Color.bg.toArgb()
|
||||
for (x in left + 32 until left + 224) assertEquals("$name $watchState focus=$focus straight edge ($x,$bottom)",
|
||||
expected, bitmap.getPixel(x, bottom))
|
||||
if (watchState != "unwatched") {
|
||||
val fillY = if (focus) 655 else 640
|
||||
assertEquals("$name $watchState fill", WolfskinTokens.Color.accent.toArgb(), bitmap.getPixel(left + 32, fillY))
|
||||
val right = if (watchState == "resume") androidx.compose.ui.graphics.Color(0xFF3D3D3F) else WolfskinTokens.Color.accent
|
||||
assertEquals("$name $watchState track", right.toArgb(), bitmap.getPixel(left + 224, fillY))
|
||||
}
|
||||
}
|
||||
reference.recycle()
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Caption fitting and row pitches are measured from the same surfaces used by the TV. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class CardGeometryTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
@Test fun narrowCaptionsEllipsiseOnlyTheTitleAndKeepRuntimeAndCheck() {
|
||||
val width = mutableStateOf(105.dp)
|
||||
rule.setContent { WolfskinTheme {
|
||||
CardCaption("Steins;Gate: The Movie - Load Region of Déjà Vu", 2007, "999h 59m", true, true,
|
||||
Modifier.width(width.value).testTag("caption"))
|
||||
} }
|
||||
for (pixels in listOf(210, 327)) {
|
||||
rule.runOnIdle { width.value = (pixels / 2f).dp }
|
||||
rule.waitForIdle()
|
||||
val layouts = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().map { node ->
|
||||
val results = mutableListOf<TextLayoutResult>()
|
||||
node.config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results)
|
||||
results.single()
|
||||
}
|
||||
val title = layouts.single { it.layoutInput.text.text.startsWith("Steins;") }
|
||||
assertEquals(1, title.lineCount)
|
||||
assertTrue("Only the long title ellipsises at $pixels", title.isLineEllipsized(0))
|
||||
for (facts in layouts.filter { it !== title }) {
|
||||
assertFalse("Metadata must not overflow at $pixels", facts.hasVisualOverflow)
|
||||
assertFalse(facts.isLineEllipsized(0))
|
||||
}
|
||||
val caption = rule.onNodeWithTag("caption").fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(58f, caption.height, .1f)
|
||||
assertEquals(pixels.toFloat(), caption.width, .1f)
|
||||
val check = rule.onNodeWithContentDescription("Watched").fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(24f, check.width, .1f)
|
||||
assertEquals(24f, check.height, .1f)
|
||||
assertTrue(check.right <= caption.right)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun unfocusedLibraryTitlesKeepCardWidthAndGridPitch() {
|
||||
val titles = List(12) { LibraryTitle("$it", "A long title occupying only one card column ${it.toString().padStart(2, '0')}", "movie", 2007, 10560, poster = "/library/assets/movie-annie-hall-1977-poster.jpg") }
|
||||
val shell = ShellState(fixturePeople(), "shared").apply { pickerVisible = false; enterDestination("movies") }
|
||||
val grid = LibraryGridState("Movies", titles)
|
||||
rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it, offlineArt = ::fixtureArt) } } }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val first = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot
|
||||
val second = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot
|
||||
// Focus scales around the unchanged slot centre.
|
||||
assertEquals(372f, first.top + (first.height - 384f) / 2, 1f)
|
||||
assertEquals(524f, second.top - (first.top + (first.height - 384f) / 2), 1f)
|
||||
for (index in 0..5) {
|
||||
val caption = rule.onNodeWithText(titles[index].title, useUnmergedTree = true).fetchSemanticsNode()
|
||||
assertEquals(256f, caption.boundsInRoot.width, .1f)
|
||||
assertEquals(794f, caption.boundsInRoot.top, 1f)
|
||||
}
|
||||
val focusedCaption = rule.onNodeWithText(titles[0].title, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(20.48f, focusedCaption.top - first.bottom - first.height * .04f - 2f * 1.08f, 1f)
|
||||
assertEquals(26.48f, second.top - first.height * .04f - 2f * 1.08f - (focusedCaption.top + 58f), 1f)
|
||||
rule.runOnIdle { kotlinx.coroutines.runBlocking { shell.view.listState.scrollToItem(0, 204) } }
|
||||
rule.waitForIdle()
|
||||
val pinnedFirst = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot
|
||||
val pinnedSecond = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(168f, pinnedFirst.top, 1f)
|
||||
assertEquals(692f, pinnedSecond.top, 1f)
|
||||
assertEquals(1076f, pinnedSecond.bottom, 1f)
|
||||
}
|
||||
|
||||
@Test fun homePosterWideAndTileShelvesReserveTheirCaptionBands() {
|
||||
val kind = mutableStateOf(HomeCardKind.Poster)
|
||||
val loading = mutableStateOf(false)
|
||||
val memory = HomeViewMemory()
|
||||
rule.setContent { WolfskinTheme {
|
||||
Column {
|
||||
Box(Modifier.testTag("shelf")) {
|
||||
HomeShelf(HomeRow("fixture", HomeRowKind.Shelf, "Fixture", listOf(HomeTitle("one", "One", "movie", 2007, 10560)),
|
||||
kind.value, skeleton = loading.value), memory, false, 0, emptyList(), {}, {})
|
||||
}
|
||||
Box(Modifier.size(1.dp).testTag("next"))
|
||||
}
|
||||
} }
|
||||
for ((cardKind, expected) in listOf(HomeCardKind.Poster to 632f, HomeCardKind.Wide to 522f, HomeCardKind.Tile to 441f)) {
|
||||
for (pending in listOf(false, true)) {
|
||||
rule.runOnIdle { kind.value = cardKind; loading.value = pending }
|
||||
rule.waitForIdle()
|
||||
assertEquals("$cardKind loading=$pending", expected, rule.onNodeWithTag("shelf").fetchSemanticsNode().boundsInRoot.height, .1f)
|
||||
assertEquals(expected, rule.onNodeWithTag("next").fetchSemanticsNode().boundsInRoot.top, .1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun filmographyKeepsUnfocusedMetadataAndDistinguishesSeriesFromFilmRuntime() {
|
||||
val path = "/library/assets/movie-annie-hall-1977-poster.jpg"
|
||||
val item = mutableStateOf(TitleItem("credit", "A filmography credit", "movie", 2007, 10560,
|
||||
poster = path, held = true, watched = true))
|
||||
val art = mapOf(path to fixtureArt(path)!!)
|
||||
rule.setContent { WolfskinTheme {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleBitmaps provides art) {
|
||||
TitlePoster(item.value, false, alwaysShowCaption = true) {}
|
||||
}
|
||||
} }
|
||||
rule.onNodeWithText("A filmography credit").assertIsDisplayed()
|
||||
rule.onNodeWithText("2007").assertIsDisplayed()
|
||||
rule.onNodeWithText("2h 56m").assertIsDisplayed()
|
||||
rule.onNodeWithContentDescription("Watched").assertIsDisplayed()
|
||||
rule.runOnIdle { item.value = item.value.copy(kind = "tv", year = 2016, runtimeSeconds = 123456) }
|
||||
rule.onNodeWithText("2016").assertIsDisplayed()
|
||||
rule.onNodeWithText("2h 56m").assertDoesNotExist()
|
||||
rule.onNodeWithContentDescription("Watched").assertIsDisplayed()
|
||||
rule.runOnIdle { item.value = item.value.copy(resumeSeconds = 10) }
|
||||
rule.onNodeWithContentDescription("Watched").assertDoesNotExist()
|
||||
rule.onNodeWithText("A filmography credit").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
|
@ -11,23 +11,12 @@ import org.junit.runner.RunWith
|
|||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Caption spacing and live rail changes must preserve poster geometry and retained focus. */
|
||||
/** Live letter-rail changes preserve deep selection and poster geometry. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class LibrarySpacingRegressionTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
@Test fun partiallyWatchedPosterKeepsItsOriginalProgressInsets() {
|
||||
val title = LibraryTitle("partial", "Partially watched", "movie", progress = .5f)
|
||||
rule.setContent { WolfskinTheme { LibraryPoster(title, false, false, false) } }
|
||||
val poster = rule.onNodeWithTag("poster-partial").fetchSemanticsNode().boundsInRoot
|
||||
val track = rule.onNodeWithTag("poster-progress-partial").fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(256f, poster.width, .1f)
|
||||
assertEquals(220f, track.width, .1f)
|
||||
assertEquals(18f, track.left - poster.left, .1f)
|
||||
assertEquals(18f, poster.right - track.right, .1f)
|
||||
}
|
||||
|
||||
@Test fun crossingRailThresholdKeepsTheSameDeepFocusedPosterVisible() {
|
||||
val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") }
|
||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("shows") }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue