Integrate native Home and replay full title launch paths

This commit is contained in:
justinzeus 2026-11-05 21:49:34 +01:00
commit fc02ce8646
697 changed files with 2956 additions and 146 deletions

View file

@ -65,7 +65,7 @@ class DiscoverCaptureTest {
}
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "discover").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
recordTypography(directory, name)
}

View file

@ -0,0 +1,26 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** An unreadable local image exercises Coil's real error path without a network provider. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class HomeArtTest {
@get:Rule val rule = createComposeRule()
@Test fun imageFailureShowsTheTitleFallbackInsteadOfAnEmptyCard() {
val title = HomeTitle("missing-image", "A missing film", "movie", poster = "file:///android_asset/wolfskin-home/not-present.jpg")
rule.setContent { WolfskinTheme { TitleArtwork(title.title, title.type, title.poster, WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)) } }
rule.waitUntil(5000) { rule.onAllNodesWithText(title.title).fetchSemanticsNodes().isNotEmpty() }
rule.onNodeWithText(title.title).assertIsDisplayed()
}
}

View file

@ -0,0 +1,119 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import org.json.JSONArray
import org.json.JSONObject
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import java.util.Locale
import kotlinx.coroutines.runBlocking
import org.junit.Before
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
/** Every frozen Home acceptance state renders the actual native Compose surface at density 2. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HomeCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Before fun fullscreen() {
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
}
private fun capture(name: String, person: String = "justin", mode: String = "ready", row: String = "hero", column: Int = 0, nav: Int? = null) {
val previous = Locale.getDefault()
Locale.setDefault(Locale.US)
try {
val snapshot = HomeFixture(rule.activity).snapshot(person, mode)
val shell = ShellState(fixturePeople(), person).apply {
pickerVisible = false
view.row = snapshot.rows.indexOfFirst { it.id == row }.coerceAtLeast(0)
view.cards[row] = column
if (nav != null) { navFocused = true; navIndex = nav }
}
preload(snapshot, shell.view.row)
rule.setContent { WolfskinTheme {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> HomeScreen(shell, snapshot, modifier) {} }
} }
rule.mainClock.advanceTimeBy(1200)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "home").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
recordTextLayouts(File(directory, "$name-layout.json"))
} finally { Locale.setDefault(previous) }
}
private fun recordTextLayouts(output: File) {
val nodes = JSONArray()
rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
val results = mutableListOf<TextLayoutResult>()
node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(results)
results.forEach { layout ->
val lines = JSONArray()
repeat(layout.lineCount) { line ->
lines.put(JSONObject().put("top", layout.getLineTop(line)).put("bottom", layout.getLineBottom(line)).put("baseline", layout.getLineBaseline(line)))
}
nodes.put(JSONObject().put("text", layout.layoutInput.text.text)
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top)
.put("width", layout.size.width).put("height", layout.size.height)
.put("lineHeight", layout.layoutInput.style.lineHeight.toString())
.put("lineHeightStyle", layout.layoutInput.style.lineHeightStyle.toString()).put("lines", lines))
}
}
output.writeText(nodes.toString(2))
}
private fun preload(snapshot: HomeSnapshot, focused: Int) = runBlocking {
val rows = if (focused == 0) snapshot.rows.take(2) else snapshot.rows.drop(focused).take(3)
for (row in rows) for (title in row.cards.take(8)) {
val hero = row.kind == HomeRowKind.Hero
val landscape = hero || row.cardKind != HomeCardKind.Poster
val width = if (hero) 1280 else (row.cardWidth.value * 2).toInt()
val height = if (hero) 720 else (row.cardHeight.value * 2).toInt()
val source = if (landscape) title.backdrop ?: title.poster else title.poster
if (source != null) WolfskinImages.loader(rule.activity).execute(WolfskinImages.request(rule.activity, source, width, height, !landscape))
}
}
@Test fun st005_loading() = capture("st-005", mode = "loading", nav = 0)
@Test fun st006_justinNext() = capture("st-006")
@Test fun st007_mindelResume() = capture("st-007", person = "mindel")
@Test fun st008_sharedResume() = capture("st-008", person = "shared")
@Test fun st009_deepTrending() = capture("st-009", row = "trending", column = 2)
@Test fun st010_heroNav() = capture("st-010", nav = 0)
@Test fun st011_deepBackNav() = capture("st-011", row = "lib-shows", column = 2, nav = 0)
@Test fun st012_explore() = capture("st-012", row = "explore")
@Test fun st013_unsigned() = capture("st-013", mode = "unsigned", row = "signin")
@Test fun st014_discoverNav() = capture("st-014", nav = 1)
@Test fun st015_offlineSaved() = capture("st-015", mode = "offline")
@Test fun st016_conductarrDown() = capture("st-016", mode = "cond-down", row = "cond-down")
@Test fun st017_conductarrSaved() = capture("st-017", mode = "cond-cached")
@Test fun st018_jellyfinDown() = capture("st-018", mode = "jf-down")
@Test fun st019_picksUnavailable() = capture("st-019", mode = "picks-unavailable", row = "trending")
@Test fun st020_missingArt() = capture("st-020", person = "shared", mode = "missing", row = "lib-movies")
@Test fun st096_widestTilePlate() = capture("st-096", person = "mindel", row = "new", column = 4)
@Test fun st100_episodePlate() = capture("st-100", person = "mindel", row = "nu")
@Test fun st099_posterPlate() = capture("st-099", row = "lib-movies", column = 1)
@Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2)
@Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4)
@Test fun st103_heroRatings() = capture("st-103")
@Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m")
}

View file

@ -0,0 +1,224 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.*
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import org.json.JSONArray
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
/** Approved replay data drives the same Android event handling used by the Home surface. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class HomeKeysTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun approvedHomePathsMatchFocusAndCounts() {
val fixture = HomeFixture(rule.activity)
var shell by mutableStateOf(ShellState(fixturePeople(), "justin"))
var action: HomeAction? = null
rule.setContent { WolfskinTheme {
val snapshot = remember(shell, shell.person) { fixture.snapshot(shell.person.key) }
ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } }
} }
val paths = javaClass.getResourceAsStream("/wolfskin-home-presses.json")!!.bufferedReader().use { JSONArray(it.readText()) }
for (index in 0 until paths.length()) {
val path = paths.getJSONObject(index)
val keys = path.getJSONArray("keys")
rule.runOnIdle { shell = ShellState(fixturePeople(), "justin"); action = null }
for (key in 0 until keys.length()) press(keys.getString(key))
assertEquals(path.getString("goal"), path.getInt("presses"), keys.length())
when (index) {
0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play); assertTrue((action as HomeAction.Play).resume) }
1 -> { assertTrue(action is HomeAction.Play); assertFalse((action as HomeAction.Play).resume); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) }
2 -> assertEquals("trending", fixture.snapshot("justin").rows[shell.view.row].id)
3 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("trending", row.id); assertEquals(row.cards.size, shell.view.cards[row.id]); assertTrue(row.hasSeeAll) }
4 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("lib-shows", row.id); assertEquals(2, shell.view.cards[row.id]); assertFalse(shell.navFocused) }
5 -> assertEquals("discover", shell.route)
}
}
}
@Test fun cardsActivateOnlyOnReleaseAndMovementCancelsArming() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val snapshot = HomeFixture(rule.activity).snapshot("justin")
var action: HomeAction? = null
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } }
press("DOWN")
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(Key.DirectionCenter) }
assertNull(action)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyUp(Key.DirectionCenter) }
assertTrue(action is HomeAction.Details)
rule.runOnIdle { action = null }
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(Key.DirectionCenter); pressKey(Key.DirectionRight); keyUp(Key.DirectionCenter) }
assertNull(action)
}
@Test fun loadingHasNavFocus() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val snapshot = HomeFixture(rule.activity).snapshot("justin", "loading")
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } } }
rule.waitForIdle()
assertTrue(shell.navFocused)
assertEquals(0, shell.navIndex)
}
@Test fun returningHomeRestoresTheExactViewportAndArrowReentersVisibleContent() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val snapshot = HomeFixture(rule.activity).snapshot("justin")
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier ->
if (shell.route == "home") HomeScreen(shell, snapshot, modifier) {} else ShellPlaceholder(shell.route, modifier)
} } }
rule.onNodeWithTag("wolfskin-home").performScrollToIndex(3)
val view = shell.view
val savedIndex = view.listState.firstVisibleItemIndex
val savedOffset = view.listState.firstVisibleItemScrollOffset
assertEquals(3, savedIndex)
rule.runOnIdle { shell.enterDestination("movies") }
rule.runOnIdle { shell.enterDestination("home") }
rule.waitForIdle()
assertSame(view, shell.view)
assertEquals(savedIndex, view.listState.firstVisibleItemIndex)
assertEquals(savedOffset, view.listState.firstVisibleItemScrollOffset)
press("RIGHT")
assertEquals("lib-movies", snapshot.rows[view.row].id)
assertEquals(0, view.cards["lib-movies"])
}
@Test fun pointerHoverMovesFocusWithoutActivatingTheCard() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val snapshot = HomeFixture(rule.activity).snapshot("justin")
var action: HomeAction? = null
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } }
press("DOWN")
rule.onNodeWithTag("home-card:nu:2").performMouseInput { enter(center) }
rule.waitForIdle()
assertEquals("nu", snapshot.rows[shell.view.row].id)
assertEquals(2, shell.view.cards["nu"])
assertNull(action)
}
@Test fun downClampsToShortRowsAndNeverMovesPastTheFinalSeeAll() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false }
val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards
val long = HomeRow(id = "long", kind = HomeRowKind.Shelf, cards = cards)
val short = long.copy(id = "short", cards = cards.take(2), total = 2)
val tail = short.copy(id = "tail", total = 20)
shell.view.cards[long.id] = cards.lastIndex
var action: HomeAction? = null
rule.setContent { WolfskinTheme {
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(long, short, tail)), modifier) { action = it } }
} }
press("DOWN")
assertEquals(short.id, listOf(long, short, tail)[shell.view.row].id)
assertEquals(1, shell.view.cards[short.id])
rule.onNodeWithTag("home-card:short:1").assertIsDisplayed()
press("RIGHT")
assertEquals(1, shell.view.cards[short.id])
press("DOWN")
press("RIGHT")
press("DOWN")
assertEquals(2, shell.view.row)
assertEquals(2, shell.view.cards[tail.id])
rule.onNodeWithTag("home-card:tail:2").assertIsDisplayed()
press("OK")
assertEquals(tail.id, (action as HomeAction.SeeAll).row.id)
}
@Test fun mixedWidthRowsUseNearestCenterAndKeepTheTrackWhenMovingBackInsideItsMiddle() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false }
val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards
val posters = HomeRow(id = "posters", kind = HomeRowKind.Shelf, cards = cards)
val tiles = posters.copy(id = "tiles", cardKind = HomeCardKind.Tile)
shell.view.cards[posters.id] = 3
rule.setContent { WolfskinTheme {
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(posters, tiles)), modifier) {} }
} }
press("DOWN")
assertEquals("The third tile is nearest the fourth poster's horizontal center", 2, shell.view.cards[tiles.id])
repeat(4) { press("RIGHT") }
val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left
press("LEFT")
assertEquals("Moving back within the visible middle must not shift the track", left,
rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left, .1f)
press("UP")
assertEquals("A visited row retains its column", 3, shell.view.cards[posters.id])
}
@Test fun asynchronousFreshHomePlaysNextInTwoPresses() {
val fixture = HomeFixture(rule.activity)
val shell = ShellState(fixturePeople(), "justin")
val delivery = kotlinx.coroutines.CompletableDeferred<HomeSnapshot>()
var action: HomeAction? = null
rule.setContent {
val snapshot by produceState(fixture.snapshot("justin", "loading")) { value = delivery.await() }
WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } }
}
press("OK")
rule.waitForIdle()
assertTrue(shell.navFocused)
rule.runOnIdle { delivery.complete(fixture.snapshot("justin")) }
rule.waitForIdle()
assertFalse(shell.navFocused)
assertEquals(0, shell.view.cards["hero"])
press("OK")
val play = action as HomeAction.Play
assertFalse(play.resume)
assertEquals("Yu Yu Hakusho", play.title.seriesTitle)
}
@Test fun asynchronousHomeDoesNotStealFocusAfterNavInput() {
val fixture = HomeFixture(rule.activity)
val shell = ShellState(fixturePeople(), "justin")
val delivery = kotlinx.coroutines.CompletableDeferred<HomeSnapshot>()
rule.setContent {
val snapshot by produceState(fixture.snapshot("justin", "loading")) { value = delivery.await() }
WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } }
}
press("OK")
press("RIGHT")
rule.runOnIdle { delivery.complete(fixture.snapshot("justin")) }
rule.waitForIdle()
assertTrue(shell.navFocused)
assertEquals(1, shell.navIndex)
}
@Test fun heldOkRepeatEventsDoNotReplayButtonsOrActivateCardsBeforeRelease() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val snapshot = HomeFixture(rule.activity).snapshot("justin")
val actions = mutableListOf<HomeAction>()
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { actions += it } } } }
val input = rule.onNodeWithTag("wolfskin-shell")
fun repeatOk() = rule.runOnIdle {
rule.activity.dispatchKeyEvent(android.view.KeyEvent(0L, 0L, android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 3))
}
input.performKeyInput { keyDown(Key.DirectionCenter) }
repeatOk()
assertEquals(1, actions.size)
assertTrue(actions.single() is HomeAction.Play)
input.performKeyInput { keyUp(Key.DirectionCenter) }
press("DOWN")
input.performKeyInput { keyDown(Key.DirectionCenter) }
repeatOk()
assertEquals(1, actions.size)
input.performKeyInput { keyUp(Key.DirectionCenter) }
assertEquals(2, actions.size)
assertTrue(actions.last() is HomeAction.Details)
}
private fun press(name: String) {
val key = when (name) {
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown
"LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight
"BACK" -> Key.Back; else -> Key.DirectionCenter
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
}
}

View file

@ -0,0 +1,38 @@
package org.justinvisser.wolfskin.design
import java.util.Locale
import org.junit.Assert.*
import org.junit.Test
/** Cache boundaries protect visible ratings during offline browsing and fast repeated focus. */
class HomeRatingCacheTest {
private val title = HomeTitle("jellyfin-1", "A film", "movie", tmdbId = 42)
private val ratings = listOf(HomeRating("tmdb", 7.8))
@Test fun cacheIsSharedByCatalogueIdentityAndExpiresAtSixHours() {
var now = 0L
val cache = HomeRatingCache { now }
cache.save(title, ratings)
assertEquals(ratings, cache.read(title.copy(id = "another-server-copy")))
now = 6L * 60 * 60 * 1000 - 1
assertEquals(ratings, cache.read(title))
now++
assertNull(cache.read(title))
}
@Test fun cacheKeepsObservedAbsenceAndEvictsOldestBeyondCapacity() {
var now = 0L
val cache = HomeRatingCache { now++ }
cache.save(title, emptyList())
assertEquals(emptyList<HomeRating>(), cache.read(title))
repeat(300) { index -> cache.save(title.copy(id = "other-$index", tmdbId = index + 100), ratings) }
assertNull(cache.read(title))
assertEquals(ratings, cache.read(title.copy(id = "last", tmdbId = 399)))
}
@Test fun decimalScoresFollowLocaleButPercentagesDoNot() {
assertEquals("7,8", ratings.single().format(Locale.forLanguageTag("nl-NL")))
assertEquals("7.8", ratings.single().format(Locale.US))
assertEquals("87%", HomeRating("fresh", 87.9, percentage = true).format(Locale.forLanguageTag("nl-NL")))
}
}

View file

@ -0,0 +1,67 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
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 java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.assertFalse
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Real text layout proves locale formatting and the focus dwell instead of testing copied strings. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class HomeRatingsUiTest {
@get:Rule val rule = createComposeRule()
@Test fun dutchDecimalPlateStaysOnOneLine() {
val previous = Locale.getDefault()
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
try {
rule.setContent { WolfskinTheme { HomeRatingPlate(listOf(HomeRating("tmdb", 7.8)), true, Modifier.width(WolfskinTokens.Card.posterW)) } }
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithText("7,8").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) }
assertEquals(1, layouts.single().lineCount)
rule.onNodeWithText("7.8").assertDoesNotExist()
} finally { Locale.setDefault(previous) }
}
@Test fun focusDwellDefersUncachedPlateButCachedFocusReturnsImmediately() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val title = HomeTitle("dwell", "A film", "movie", ratings = listOf(HomeRating("tmdb", 7.8)))
val snapshot = HomeSnapshot(listOf(HomeRow("shelf", HomeRowKind.Shelf, "Shelf", listOf(title))), HomeAvailability.Ready)
rule.mainClock.autoAdvance = false
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } } }
rule.mainClock.advanceTimeBy(100)
rule.onNodeWithText("7.8").assertDoesNotExist()
rule.mainClock.advanceTimeBy(450)
rule.onNodeWithText("7.8").assertExists()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) }
rule.runOnIdle { assertTrue(shell.navFocused) }
rule.mainClock.advanceTimeBy(32)
rule.onNodeWithText("7.8").assertDoesNotExist()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) }
rule.runOnIdle { assertFalse(shell.navFocused) }
rule.mainClock.advanceTimeBy(32)
rule.onNodeWithText("7.8").assertExists()
}
@Test fun signedOutHomeNeverDrawsHeroOrPlateScores() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val title = HomeTitle("signed-out", "A film", "movie", ratings = listOf(HomeRating("tmdb", 7.8)))
val rows = buildHomeRows(HomeLibrary(nextUp = listOf(title)), listOf(homeDiscoverState(HomeAvailability.SignedOut)))
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(rows), modifier) {} } } }
rule.mainClock.advanceTimeBy(1000)
rule.onNodeWithText("7.8").assertDoesNotExist()
}
}

View file

@ -0,0 +1,50 @@
package org.justinvisser.wolfskin.design
import org.junit.Assert.*
import org.junit.Test
/** Hero precedence and empty/error transitions preserve useful, focusable Home content. */
class HomeRowsTest {
private val resume = HomeTitle("resume", "Resume", "movie")
private val next = HomeTitle("next", "Next", "episode")
private val newest = HomeTitle("new", "New", "movie")
private val pick = HomeTitle("pick", "Pick", "movie")
private val continuation = listOf(HomeRow("pk-m", HomeRowKind.Shelf, cards = listOf(pick)))
@Test fun heroChainUsesResumeThenNextThenNewThenPickAndNeverRepeatsHeroOnShelves() {
val cases = listOf(
HomeLibrary(listOf(resume), listOf(next), listOf(newest)) to resume,
HomeLibrary(nextUp = listOf(next), newest = listOf(newest)) to next,
HomeLibrary(newest = listOf(newest)) to newest,
HomeLibrary() to pick,
)
for ((library, expected) in cases) {
val rows = buildHomeRows(library, continuation)
assertEquals(expected.id, rows.first().cards.single().id)
assertFalse(rows.filter { it.id in setOf("cw", "nu", "new") }.flatMap { it.cards }.any { it.id == expected.id })
}
}
@Test fun heldShelvesAreUnwatchedFirstAndSeeAllUsesFullDestinationCount() {
val watched = newest.copy(id = "watched", played = true, addedAt = "2026-11-05")
val older = newest.copy(id = "older", addedAt = "2026-01-01")
val recent = newest.copy(id = "recent", addedAt = "2026-10-01")
val rows = buildHomeRows(HomeLibrary(destinations = mapOf("movies" to listOf(watched, older, recent)), destinationTotals = mapOf("movies" to 84)), continuation)
val shelf = rows.single { it.id == "lib-movies" }
assertEquals(listOf("recent", "older", "watched"), shelf.cards.map { it.id })
assertEquals(84, shelf.total)
assertTrue(shelf.hasSeeAll)
}
@Test fun unavailableLibraryKeepsDiscoverAndFocusableRecoveryInsteadOfHeldShelves() {
val rows = buildHomeRows(HomeLibrary(listOf(resume), listOf(next), listOf(newest), mapOf("movies" to listOf(newest))), continuation, libraryAvailable = false)
assertEquals(listOf("hero", "jf-down", "disc-h", "pk-m"), rows.map { it.id })
assertTrue(rows.single { it.id == "jf-down" }.focusable)
}
@Test fun firstReadSkeletonHasNoFocusableContent() {
val rows = buildHomeRows(HomeLibrary(), continuation, loading = true)
assertEquals(listOf(HomeRowKind.Hero, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf), rows.map { it.kind })
assertTrue(rows.all { !it.focusable })
}
}

View file

@ -61,7 +61,7 @@ class LibraryCaptureTest {
}
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory")).apply { mkdirs() }
val directory = File(System.getProperty("wolfskin.captureDirectory"), "library").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
}

View file

@ -0,0 +1,126 @@
[
{
"goal": "Resume (Mindel, from launch)",
"keys": [
"RIGHT",
"OK",
"OK"
],
"presses": 3,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Play next (Justin has no resume)",
"keys": [
"OK",
"OK"
],
"presses": 2,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Round 3 \u00b7 Home hero \u2192 Trending (D2 split)",
"keys": [
"OK",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN"
],
"presses": 13,
"note": "12 Downs after the launch OK",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Round 3 \u00b7 Trending \u2192 its See all card (D3: 20 cards)",
"keys": [
"OK",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT",
"RIGHT"
],
"presses": 33,
"note": "20 Rights from Trending",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Round 3 \u00b7 deep Home row: Back to the nav, Down to the same card",
"keys": [
"OK",
"DOWN",
"DOWN",
"DOWN",
"DOWN",
"RIGHT",
"RIGHT",
"BACK",
"DOWN"
],
"presses": 9,
"note": "2: Back, Down",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
},
{
"goal": "Open Discover",
"keys": [
"OK",
"UP",
"RIGHT",
"OK"
],
"presses": 4,
"note": "",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
}
]