Make repeated ids safe as lazy list keys across the Wolfskin screens

This commit is contained in:
justinzeus 2026-11-07 20:31:42 +01:00
parent 2b142af8d1
commit 6f85b9c611
8 changed files with 144 additions and 10 deletions

View file

@ -0,0 +1,103 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Lazy lists throw "Key ... was already used" in measure when two items share an id. Server lists can repeat one:
* a Jellyfin person is credited once per role, so the title page crashed on a cast with the same person twice.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class DuplicateLazyKeysTest {
@get:Rule val rule = createComposeRule()
private val viewer = ShellPerson("justin", "Justin", "J", false)
private fun shell(route: String) = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination(route) }
private fun repeated(id: String, name: String = id) = TitleItem(id, name, "movie", held = true)
@Test fun repeatedKeysKeepTheFirstAndSuffixTheRest() {
assertEquals(listOf("a", "b", "a#1", "a#2", "a#1#1"), listOf("a", "b", "a", "a", "a#1").uniqueKeys())
assertEquals(listOf("a", "b"), listOf("a", "b").uniqueKeys())
}
@Test fun castCreditedForTwoRolesComposes() {
val state = TitleState(TitlePageData(repeated("film"), cast = listOf(
TitlePerson("person", "Actor", "Hero"), TitlePerson("other", "Other", "Friend"), TitlePerson("person", "Actor", "Villain"))), viewer) {}
rule.setContent { WolfskinTheme { TitleCast(state, active = false) } }
rule.waitForIdle()
assertEquals(2, rule.onAllNodesWithTag("title-cast-person").fetchSemanticsNodes().size)
rule.onNodeWithTag("title-cast-other").assertExists()
}
@Test fun relatedTitleListedTwiceComposes() {
val state = TitleState(TitlePageData(repeated("film"), related = listOf(repeated("same"), repeated("other"), repeated("same"))), viewer) {}
rule.setContent { WolfskinTheme { TitleMediaShelf(state, episodes = false, active = false) } }
rule.waitForIdle()
rule.onNodeWithTag("title-shelf:related").assertExists()
}
@Test fun episodeListedTwiceComposes() {
val series = TitleItem("series", "A series", "tv", seasons = 1, held = true)
val episode = TitleItem("episode", "Pilot", "episode", held = true, season = 1, episode = 1)
val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode, episode), connection = TitleConnection.SignedIn), viewer) {}
rule.setContent { WolfskinTheme { TitleMediaShelf(state, episodes = true, active = false) } }
rule.waitForIdle()
rule.onNodeWithTag("title-shelf:episodes").assertExists()
}
@Test fun repeatedSeasonNumberComposes() {
val series = TitleItem("series", "A series", "tv", seasons = 2, held = true)
val state = TitleState(TitlePageData(series, seasonNumbers = listOf(1, 1, 2)), viewer) {}
rule.setContent { WolfskinTheme { TitleSurface(state, shell("title/series"), Modifier.requiredSize(960.dp, 540.dp)) } }
rule.waitForIdle()
rule.onNodeWithTag("title-shelf:episodes").assertDoesNotExist()
assertTrue(state.rows.contains("seasons"))
}
@Test fun filmographyWithARepeatedCreditComposes() {
val state = PersonState(TitlePerson("person", "Actor"), List(WolfskinTokens.Behaviour.gridColumns.toInt() + 1) { repeated("credit") }, {})
rule.setContent { WolfskinTheme { PersonSurface(state, shell("title/person"), Modifier.requiredSize(960.dp, 540.dp)) } }
rule.waitForIdle()
rule.onNodeWithTag("person-page").assertExists()
}
@Test fun homeShelfWithARepeatedCardComposes() {
val card = HomeTitle("episode", "Pilot", "episode")
val snapshot = HomeSnapshot(listOf(HomeRow("cw", HomeRowKind.Shelf, "Continue watching", listOf(card, HomeTitle("other", "Other", "movie"), card), HomeCardKind.Wide)), HomeAvailability.Ready)
val shell = shell("home")
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } } }
rule.waitForIdle()
rule.onNodeWithTag("home-card:cw:0").assertExists()
rule.onNodeWithTag("home-card:cw:2").assertExists()
}
@Test fun libraryGridWithARepeatedTitleComposes() {
val columns = WolfskinTokens.Behaviour.gridColumns.toInt()
val titles = List(columns * 2) { LibraryTitle("same", "Same title", "movie") }
val shell = shell("movies")
val grid = LibraryGridState("Movies", titles)
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> LibraryScreen(shell, grid, modifier) } } }
rule.waitForIdle()
rule.onNodeWithTag("library-grid").assertExists()
}
@Test fun requestListedTwiceComposes() {
val request = ComingInRequest("r1", "movie", 1, "A film", "justin", "Justin", null, null, ComingInStage.Waiting)
val shell = shell("settings").apply { requests.update(ComingInSnapshot(listOf(request, request.copy(id = "r2"), request), ComingInStatus.Ready, signedIn = true)); openRequests() }
rule.setContent { WolfskinTheme { ShellSurface(shell) } }
rule.waitForIdle()
rule.onNodeWithTag("wolfskin-shell").assertExists()
}
}