Integrate reviewed Requests and Discover surfaces
Some checks failed
review/design-claude Changes requested: 13 blocking design/behaviour findings (see PR comment)
|
|
@ -0,0 +1,84 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/** The fake coroutine clock proves the production dwell/cache decision without a Compose scheduler. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ComingInRatingsGateTest {
|
||||
private val first = ComingInRequest("first", "movie", 1, "First", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting,
|
||||
ratings = listOf(ComingInRating("imdb", 6.0)))
|
||||
private val second = first.copy(id = "second", tmdbId = 2, title = "Second", ratings = listOf(ComingInRating("tmdb", 7.2)))
|
||||
|
||||
@Test fun uncachedDwellCancelsOnLeaveAndReentryStartsAFreshRead() = runTest {
|
||||
val state = ComingInState()
|
||||
state.update(ComingInSnapshot(listOf(first, second), ComingInStatus.Ready, signedIn = true))
|
||||
val abandoned = launch { state.awaitFocusedRatings() }
|
||||
runCurrent()
|
||||
advanceTimeBy(100)
|
||||
assertFalse(state.ratingsVisible)
|
||||
abandoned.cancel()
|
||||
state.focus(second.id)
|
||||
val next = launch { state.awaitFocusedRatings() }
|
||||
runCurrent()
|
||||
advanceTimeBy(349)
|
||||
runCurrent()
|
||||
assertFalse(state.ratingsVisible)
|
||||
advanceTimeBy(1)
|
||||
runCurrent()
|
||||
assertTrue(state.ratingsVisible)
|
||||
next.join()
|
||||
state.focus(first.id)
|
||||
assertFalse(state.ratingsVisible)
|
||||
launch { state.awaitFocusedRatings() }
|
||||
runCurrent()
|
||||
advanceTimeBy(350)
|
||||
runCurrent()
|
||||
assertTrue("Returning to the abandoned uncached title must complete a new dwell", state.ratingsVisible)
|
||||
state.focus(second.id)
|
||||
assertTrue("A previously read title must be visible on the focus transition itself", state.ratingsVisible)
|
||||
}
|
||||
|
||||
@Test fun signOutClearsCachedVisibilityAndSignInUsesFreshRatingAndLocale() = runTest {
|
||||
val state = ComingInState()
|
||||
val snapshot = ComingInSnapshot(listOf(first), ComingInStatus.Ready, signedIn = true)
|
||||
state.update(snapshot)
|
||||
launch { state.awaitFocusedRatings() }
|
||||
runCurrent(); advanceTimeBy(350); runCurrent()
|
||||
assertTrue(state.ratingsVisible)
|
||||
state.update(snapshot.copy(signedIn = false))
|
||||
assertFalse(state.ratingsVisible)
|
||||
val refreshed = first.copy(ratings = listOf(ComingInRating("imdb", 7.6)))
|
||||
state.update(snapshot.copy(items = listOf(refreshed)))
|
||||
assertFalse("Sign-out invalidates the previous session's rating gate", state.ratingsVisible)
|
||||
launch { state.awaitFocusedRatings() }
|
||||
runCurrent(); advanceTimeBy(350); runCurrent()
|
||||
assertTrue(state.ratingsVisible)
|
||||
val rating = state.focused!!.ratings.single()
|
||||
assertEquals("7,6", formatComingInRating(rating, Locale.forLanguageTag("nl-NL")))
|
||||
assertEquals("7.6", formatComingInRating(rating, Locale.ENGLISH))
|
||||
assertEquals("87%", formatComingInRating(ComingInRating("fresh", 87.9, true), Locale.forLanguageTag("nl-NL")))
|
||||
}
|
||||
|
||||
@Test fun savedOfflineRowsOnlyShowPreviouslyReadRatingsAcrossHouseholdRequests() = runTest {
|
||||
val state = ComingInState()
|
||||
val sharedCopy = first.copy(id = "other-request", requesterKey = "shared", requesterName = "Shared")
|
||||
val online = ComingInSnapshot(listOf(first, second, sharedCopy), ComingInStatus.Ready, signedIn = true)
|
||||
state.update(online)
|
||||
launch { state.awaitFocusedRatings() }
|
||||
runCurrent(); advanceTimeBy(350); runCurrent()
|
||||
state.update(online.copy(status = ComingInStatus.Unavailable))
|
||||
state.focus(second.id)
|
||||
state.awaitFocusedRatings()
|
||||
assertFalse("An unread title cannot acquire ratings while offline", state.ratingsVisible)
|
||||
state.focus(sharedCopy.id)
|
||||
assertTrue("The title cache is shared across request rows for the same media type and TMDB id", state.ratingsVisible)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
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.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Captures execute the shipped Compose surface with offline fixture artwork and actual shell focus. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class DiscoverCaptureTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val fixture = discoverTestFixture()
|
||||
|
||||
@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", snapshotTransform: (DiscoverSnapshot) -> DiscoverSnapshot = { it }, configure: (DiscoverState) -> Unit = {}) {
|
||||
val titleState = name.substringAfter("st-").toInt() in setOf(58, 59, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 87, 110)
|
||||
val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination(if (titleState) "home" else "discover") }
|
||||
val snapshot = snapshotTransform(fixture.snapshot(person))
|
||||
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, person), CoroutineScope(Dispatchers.Main.immediate),
|
||||
onPush = shell::enterDestination, onPop = { shell.enterDestination("discover") }, serviceLanes = fixture::service)
|
||||
configure(state)
|
||||
val painters = mutableMapOf<String, ImageBitmap>()
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
CompositionLocalProvider(LocalDiscoverArt provides { path -> painters.getOrPut(path) { BitmapFactory.decodeFile(path).asImageBitmap() } }) {
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
||||
recordTypography(directory, name)
|
||||
}
|
||||
|
||||
private fun recordTypography(directory: File, name: String) {
|
||||
val rows = JSONArray()
|
||||
rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsProperties.Text), useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
|
||||
val layouts = mutableListOf<TextLayoutResult>()
|
||||
if (SemanticsActions.GetTextLayoutResult in node.config) node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
|
||||
layouts.forEach { layout ->
|
||||
val text = layout.layoutInput.text.text
|
||||
val lines = JSONArray()
|
||||
repeat(layout.lineCount) { index -> lines.put(text.substring(layout.getLineStart(index), layout.getLineEnd(index, visibleEnd = true))) }
|
||||
rows.put(JSONObject().put("text", text).put("lines", layout.lineCount).put("lineTexts", lines)
|
||||
.put("fontSizeSp", layout.layoutInput.style.fontSize.value).put("fontWeight", layout.layoutInput.style.fontWeight?.weight)
|
||||
.put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL).put("trim", layout.layoutInput.style.lineHeightStyle?.trim.toString())
|
||||
.put("density", layout.layoutInput.density.density).put("fontScale", layout.layoutInput.density.fontScale)
|
||||
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height))
|
||||
}
|
||||
}
|
||||
File(directory, "$name-type-metrics.json").writeText(rows.toString(2))
|
||||
}
|
||||
|
||||
@Test fun st029_discover() = capture("st-029")
|
||||
@Test fun st030_picksCollection() = capture("st-030") { state -> state.openCollection(state.lanes.first { it.id == "pk-movie" }) }
|
||||
@Test fun st031_hidden() = capture("st-031") { hidden(it) }
|
||||
@Test fun st032_restore() {
|
||||
capture("st-032") { state -> hidden(state); state.overlay = DiscoverOverlayState.Restore(state.snapshot.picks[7]) }
|
||||
val title = rule.onAllNodesWithText(fixture.snapshot("justin").picks[7].title, useUnmergedTree = true)
|
||||
.fetchSemanticsNodes().maxBy { it.boundsInRoot.left }.boundsInRoot
|
||||
val sub = rule.onNodeWithText("Hidden from Justin’s picks.", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
val gap = with(rule.density) { (WolfskinTokens.Discover.sheetGap + WolfskinTokens.Discover.sheetSubTop).toPx() }
|
||||
assertTrue("Restore subtitle must retain the negative top margin", kotlin.math.abs(sub.top - title.bottom - gap) <= 1f)
|
||||
}
|
||||
@Test fun st033_failedBadge() = capture("st-033") { state ->
|
||||
val lane = state.lanes.first { it.id == "pk-movie" }
|
||||
state.requests[lane.titles[1].key] = listOf(DiscoverRequestResult(RequestPhase.Receipt, "Justin", "Failed", requester = "justin"))
|
||||
state.focus(state.lanes.indexOf(lane) + 2, 1)
|
||||
}
|
||||
@Test fun st034_retryTail() = capture("st-034", snapshotTransform = { snapshot ->
|
||||
snapshot.copy(lanes = snapshot.lanes.map { if (it.id == "trending") it.copy(tail = DiscoverTail.Retry) else it })
|
||||
}) { state -> val lane = state.lanes.first { it.id == "trending" }; state.focus(state.lanes.indexOf(lane) + 2, lane.titles.size) }
|
||||
@Test fun st035_duplicateTail() = capture("st-035") { state ->
|
||||
val titles = state.snapshot.lanes.first { it.id == "trending" }.fullTitles.take(36)
|
||||
state.openCollection(DiscoverLane("trending", "Trending", titles, tail = DiscoverTail.ShowMore))
|
||||
state.collection!!.index = state.collection!!.tailIndex
|
||||
}
|
||||
@Test fun st036_needsSignIn() {
|
||||
capture("st-036", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn) })
|
||||
val button = rule.onNodeWithText("Try again").fetchSemanticsNode().boundsInRoot
|
||||
val minimum = with(rule.density) { WolfskinTokens.Layout.buttonHeight.toPx() }
|
||||
assertTrue("The sign-in button must not shrink to fit the scroll row", button.height >= minimum)
|
||||
}
|
||||
@Test fun st037_series() {
|
||||
capture("st-037", person = "mindel") { it.filter = "tv" }
|
||||
val title = rule.onNodeWithText("56 Days", useUnmergedTree = true).fetchSemanticsNode()
|
||||
val layouts = mutableListOf<TextLayoutResult>()
|
||||
title.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
|
||||
val layout = layouts.single()
|
||||
val cssHeight = with(rule.density) { DiscoverType.hero.lineHeight.toPx() } * layout.lineCount
|
||||
assertTrue("Natural font height must not inflate the hero CSS line box", kotlin.math.abs(title.boundsInRoot.height - cssHeight) <= 1f)
|
||||
}
|
||||
@Test fun st038_noSeeds() = capture("st-038", snapshotTransform = { it.copy(status = DiscoverStatus.NoSeeds) })
|
||||
@Test fun st039_trendingCollection() = capture("st-039") { state -> state.openCollection(state.lanes.first { it.id == "trending" }) }
|
||||
@Test fun st040_service() = capture("st-040") { state -> state.openService(state.snapshot.services.first()) }
|
||||
@Test fun st041_unreachable() = capture("st-041", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.Unavailable) })
|
||||
@Test fun st058_hiddenReceipt() = capture("st-058") { state -> state.openTitle(state.snapshot.picks.first()); state.titleButton = 2; state.hide(state.title!!) }
|
||||
@Test fun st059_hiddenTitle() = capture("st-059") { state -> state.hide(state.snapshot.picks.first()); state.overlay = null; state.openTitle(state.snapshot.picks.first()); state.titleButton = 2 }
|
||||
@Test fun st071_requestSelf() = capture("st-071") { request(it) }
|
||||
@Test fun st072_requestOther() = capture("st-072") { request(it, other = true) }
|
||||
@Test fun st073_addHeld() = capture("st-073") { state -> request(state, fixture.find("The Girl Who Leapt Through Time"), other = true) }
|
||||
@Test fun st074_receipt() = capture("st-074") { request(it, other = true, phase = RequestPhase.Receipt) }
|
||||
@Test fun st075_notConfirmed() = capture("st-075") { request(it, phase = RequestPhase.NotConfirmed) }
|
||||
@Test fun st076_pending() = capture("st-076") { request(it, phase = RequestPhase.Pending) }
|
||||
@Test fun st077_refused() = capture("st-077", person = "mindel") { request(it, other = true, phase = RequestPhase.Refused) }
|
||||
@Test fun st078_recipientsLoading() = capture("st-078") { request(it, other = true, phase = RequestPhase.LoadingRecipients) }
|
||||
@Test fun st079_recipientsUnavailable() = capture("st-079") { request(it, other = true, phase = RequestPhase.RecipientsUnavailable) }
|
||||
@Test fun st080_seasonsUnknown() = capture("st-080") { state -> request(state, state.snapshot.picks.first { it.type == "tv" }) }
|
||||
@Test fun st087_sharedReceipt() = capture("st-087", person = "shared") { request(it, phase = RequestPhase.Receipt) }
|
||||
@Test fun st105_pickPlate() = capture("st-105") { state -> val lane = state.lanes.first { it.id == "pk-movie" }; state.focus(state.lanes.indexOf(lane) + 2, 1) }
|
||||
@Test fun st110_heldRatings() = capture("st-110") { state -> request(state, fixture.find("The Girl Who Leapt Through Time"), other = true) }
|
||||
|
||||
private fun hidden(state: DiscoverState) {
|
||||
state.hide(state.snapshot.picks[7]); state.hide(state.snapshot.picks[20]); state.overlay = null
|
||||
val lane = state.lanes.first { it.hidden }
|
||||
state.focus(state.lanes.indexOf(lane) + 2, lane.titles.indexOfFirst { it.key == state.snapshot.picks[7].key })
|
||||
}
|
||||
|
||||
private fun request(state: DiscoverState, title: DiscoverTitle = state.snapshot.picks.first(), other: Boolean = false, phase: RequestPhase = RequestPhase.Choose) {
|
||||
state.openTitle(title); state.openRequest(other, phase = phase)
|
||||
if (phase == RequestPhase.Receipt) {
|
||||
val sheet = (state.overlay as DiscoverOverlayState.Request).state
|
||||
val receipt = DiscoverRequestResult(phase, state.shell.person.name, requester = sheet.selectedPerson.key)
|
||||
sheet.result = receipt; state.requests[title.key] = listOf(receipt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun discoverTestFixture(): DiscoverFixture {
|
||||
val json = DiscoverCaptureTest::class.java.getResourceAsStream("/wolfskin-discover.json")!!.bufferedReader().use { it.readText() }
|
||||
val assets = File("../app/src/debug/assets/wolfskin-discover").canonicalFile
|
||||
return DiscoverFixture(json) { File(assets, it).absolutePath }
|
||||
}
|
||||
|
||||
/** In-process effects are inspectable without exposing any live client or endpoint. */
|
||||
internal class RecordingDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations {
|
||||
val sent = mutableListOf<DiscoverRequest>()
|
||||
var checked = 0
|
||||
var next = RequestPhase.Receipt
|
||||
var mutationSucceeds = true
|
||||
override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { sent += intent; return DiscoverRequestResult(next, actor.replaceFirstChar { it.uppercase() }) }
|
||||
override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult { checked++; return DiscoverRequestResult(next, actor.replaceFirstChar { it.uppercase() }) }
|
||||
override suspend fun hide(profile: String, title: DiscoverTitle) = mutationSucceeds
|
||||
override suspend fun restore(profile: String, title: DiscoverTitle) = mutationSucceeds
|
||||
override suspend fun refresh() = snapshot
|
||||
}
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.json.JSONObject
|
||||
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
|
||||
|
||||
/** Acceptance data drives real Android events through the shell's production focus adapter. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class DiscoverKeysTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
private val fixture = discoverTestFixture()
|
||||
private val shell = ShellState(fixturePeople(), "justin")
|
||||
private val snapshot = fixture.snapshot("justin")
|
||||
private val operations = RecordingDiscoverOperations(snapshot, "justin")
|
||||
private val state = DiscoverState(shell, snapshot, operations, CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination,
|
||||
{ shell.enterDestination("discover") }, fixture::service)
|
||||
|
||||
private fun launch() {
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
|
||||
if (shell.route == "discover" || shell.route.startsWith("discover-")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun press(first: Key, second: Key? = null) = listOfNotNull(first, second).forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
|
||||
|
||||
private fun replay(goal: String) {
|
||||
val paths = JSONObject(javaClass.getResourceAsStream("/discover-keys.json")!!.bufferedReader().use { it.readText() }).getJSONArray("pressPaths")
|
||||
val path = (0 until paths.length()).map { paths.getJSONObject(it) }.single { it.getString("goal") == goal }
|
||||
val keys = path.getJSONArray("keys")
|
||||
assertEquals(path.getInt("presses"), keys.length())
|
||||
launch()
|
||||
repeat(keys.length()) { index -> press(when (keys.getString(index)) {
|
||||
"OK" -> Key.DirectionCenter; "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown; "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight; else -> Key.Back
|
||||
}) }
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
@Test fun openDiscoverInFourPresses() {
|
||||
replay("Open Discover")
|
||||
assertEquals("discover", shell.route)
|
||||
assertFalse(shell.navFocused)
|
||||
assertEquals("The Amazing Spider-Man", state.hero?.title)
|
||||
}
|
||||
|
||||
@Test fun requestForYourselfInSevenPresses() {
|
||||
replay("Request a pick for yourself")
|
||||
val sheet = (state.overlay as DiscoverOverlayState.Request).state
|
||||
assertEquals(RequestPhase.Receipt, sheet.phase)
|
||||
assertEquals("justin", operations.sent.single().requester)
|
||||
assertEquals("Justin", sheet.result?.actor)
|
||||
assertEquals("Waiting", state.requests.getValue(requireNotNull(state.title).key).single().stage)
|
||||
}
|
||||
|
||||
@Test fun requestForMindelInNinePresses() {
|
||||
replay("Request a pick for Mindel")
|
||||
val sheet = (state.overlay as DiscoverOverlayState.Request).state
|
||||
assertEquals(RequestPhase.Receipt, sheet.phase)
|
||||
assertEquals("mindel", operations.sent.single().requester)
|
||||
assertEquals("Justin", sheet.result?.actor)
|
||||
}
|
||||
|
||||
@Test fun notInterestedInEightPressesAndUndoRestoresEveryIndex() {
|
||||
val original = state.lanes.map { lane -> lane.id to lane.titles.map { it.key } }
|
||||
replay("Round 3 · Not interested on a pick (Discover hero)")
|
||||
val hidden = state.overlay as DiscoverOverlayState.Hidden
|
||||
assertTrue(state.isHidden(hidden.title))
|
||||
assertTrue(state.heroes.none { it.key == hidden.title.key })
|
||||
assertTrue(state.lanes.filterNot { it.hidden }.none { lane -> lane.titles.any { it.key == hidden.title.key } })
|
||||
press(Key.DirectionCenter)
|
||||
assertNull(state.overlay)
|
||||
assertEquals("The Amazing Spider-Man", state.hero?.title)
|
||||
assertEquals(original, state.lanes.map { lane -> lane.id to lane.titles.map { it.key } })
|
||||
}
|
||||
|
||||
@Test fun heldOkOpensSheetButNeverSubmits() {
|
||||
replay("Open Discover")
|
||||
press(Key.DirectionCenter)
|
||||
rule.runOnIdle {
|
||||
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
|
||||
repeat(8) { index -> shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, index + 1))) }
|
||||
shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
|
||||
}
|
||||
assertEquals(RequestPhase.Choose, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||
assertTrue(operations.sent.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun uncertainOutcomeChecksWithoutResending() {
|
||||
operations.next = RequestPhase.NotConfirmed
|
||||
replay("Request a pick for yourself")
|
||||
assertEquals(1, operations.sent.size)
|
||||
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||
operations.next = RequestPhase.Receipt
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(1, operations.sent.size)
|
||||
assertEquals(1, operations.checked)
|
||||
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||
}
|
||||
|
||||
@Test fun closingAnUncertainReceiptNeverTurnsReopeningIntoAnotherPost() {
|
||||
operations.next = RequestPhase.NotConfirmed
|
||||
replay("Request a pick for yourself")
|
||||
press(Key.Back, Key.DirectionCenter)
|
||||
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||
operations.next = RequestPhase.Receipt
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(1, operations.sent.size)
|
||||
assertEquals(1, operations.checked)
|
||||
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||
}
|
||||
|
||||
@Test fun requestingForAnotherPersonKeepsYourOwnRequestedAction() {
|
||||
replay("Request a pick for yourself")
|
||||
press(Key.DirectionCenter)
|
||||
press(Key.DirectionRight, Key.DirectionCenter)
|
||||
press(Key.DirectionCenter, Key.DirectionCenter)
|
||||
assertEquals(setOf("justin", "mindel"), state.requests.getValue(state.title!!.key).map { it.requester }.toSet())
|
||||
press(Key.DirectionCenter)
|
||||
press(Key.DirectionLeft, Key.DirectionCenter)
|
||||
assertTrue(shell.requestsVisible)
|
||||
assertEquals(2, operations.sent.size)
|
||||
}
|
||||
|
||||
@Test fun choosingAnUncertainRecipientAgainNeverResubmits() {
|
||||
operations.next = RequestPhase.NotConfirmed
|
||||
replay("Open Discover")
|
||||
press(Key.DirectionCenter)
|
||||
press(Key.DirectionRight, Key.DirectionCenter)
|
||||
press(Key.DirectionDown, Key.DirectionCenter)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals("shared", operations.sent.single().requester)
|
||||
press(Key.Back, Key.DirectionCenter)
|
||||
press(Key.DirectionDown, Key.DirectionCenter)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(1, operations.sent.size)
|
||||
assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||
operations.next = RequestPhase.Receipt
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(1, operations.sent.size)
|
||||
assertEquals(1, operations.checked)
|
||||
assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase)
|
||||
}
|
||||
|
||||
@Test fun unknownSeriesSeasonsNeverSubmitAndBackRestoresOpener() {
|
||||
replay("Open Discover")
|
||||
rule.runOnIdle { state.openTitle(snapshot.picks.first { it.type == "tv" }); state.openRequest(false) }
|
||||
press(Key.DirectionCenter)
|
||||
assertTrue(operations.sent.isEmpty())
|
||||
assertNull(state.overlay)
|
||||
assertNotNull(state.title)
|
||||
assertFalse(shell.navFocused)
|
||||
}
|
||||
|
||||
@Test fun cardOkWaitsForReleaseAndFocusMoveCancelsActivation() {
|
||||
replay("Open Discover")
|
||||
press(Key.DirectionDown, Key.DirectionDown)
|
||||
rule.runOnIdle { shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) }
|
||||
assertNull(state.title)
|
||||
press(Key.DirectionRight)
|
||||
rule.runOnIdle { shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) }
|
||||
assertNull(state.title)
|
||||
press(Key.DirectionCenter)
|
||||
assertNotNull(state.title)
|
||||
}
|
||||
@Test fun downClampsToOccupiedCardsAndThenTheTail() {
|
||||
shell.pickerVisible = false
|
||||
val items = snapshot.picks.take(8)
|
||||
state.openCollection(DiscoverLane("short", "Picks", items, tail = DiscoverTail.ShowMore))
|
||||
launch()
|
||||
repeat(5) { press(Key.DirectionRight) }
|
||||
assertEquals(5, state.collection!!.index % state.collection!!.columns)
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(1, state.collection!!.index / state.collection!!.columns)
|
||||
assertEquals(1, state.collection!!.index % state.collection!!.columns)
|
||||
assertEquals(items.last().key, state.collection!!.items[state.collection!!.index].id)
|
||||
press(Key.DirectionDown, Key.DirectionRight)
|
||||
assertEquals(2, state.collection!!.index / state.collection!!.columns)
|
||||
assertEquals(0, state.collection!!.index % state.collection!!.columns)
|
||||
assertEquals(GridTail.ShowMore, state.collection!!.tail)
|
||||
}
|
||||
|
||||
@Test fun collectionRetryReadsNewTitlesAndOpensTheFirstAppendedTitle() {
|
||||
shell.pickerVisible = false
|
||||
val lane = state.lanes.first { it.id == "trending" }
|
||||
state.openCollection(lane.copy(titles = lane.fullTitles.take(6), fullTitles = lane.fullTitles.take(6), tail = DiscoverTail.Retry))
|
||||
val grid = state.collection!!
|
||||
grid.index = grid.tailIndex
|
||||
launch()
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(GridTail.None, grid.tail)
|
||||
assertEquals(lane.fullTitles.map { it.key }, grid.items.map { it.id })
|
||||
assertEquals(lane.fullTitles[6].key, grid.focusedTitle?.id)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(lane.fullTitles[6], state.title)
|
||||
}
|
||||
|
||||
@Test fun collectionDismissalAndUndoKeepTheOriginalOrderAndFocus() {
|
||||
shell.pickerVisible = false
|
||||
state.openCollection(state.lanes.first { it.id == "pk-movie" })
|
||||
val grid = state.collection!!
|
||||
val original = grid.items.map { it.id }
|
||||
launch()
|
||||
press(Key.DirectionCenter)
|
||||
val opener = state.title!!
|
||||
press(Key.DirectionRight, Key.DirectionRight)
|
||||
press(Key.DirectionCenter)
|
||||
assertFalse(grid.items.any { it.id == opener.key })
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(original, grid.items.map { it.id })
|
||||
assertEquals(opener.key, grid.focusedTitle?.id)
|
||||
}
|
||||
|
||||
@Test fun collectionBackRestoresTheExactOrigin() {
|
||||
shell.pickerVisible = false; shell.enterDestination("discover")
|
||||
state.focus(3, 2)
|
||||
val origin = shell.view
|
||||
state.openCollection(state.lanes.first { it.id == "pk-movie" })
|
||||
launch()
|
||||
press(Key.DirectionDown, Key.Back)
|
||||
assertNull(state.collection)
|
||||
assertSame(origin, shell.view)
|
||||
assertEquals(3, shell.view.row)
|
||||
assertEquals(2, shell.view.cards[state.rowKey])
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "nl-rNL-w960dp-h540dp-xhdpi")
|
||||
fun dutchPlateUsesLocaleAndStaysOnOneLine() {
|
||||
shell.pickerVisible = false
|
||||
val title = snapshot.picks.first().copy(ratings = listOf(DiscoverRating("tmdb", 7.8)))
|
||||
state.openCollection(DiscoverLane("locale", "Picks", listOf(title)))
|
||||
launch()
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.onNodeWithText("7,8").assertExists()
|
||||
assertEquals("7.8", formatDiscoverRating(title.ratings.single(), java.util.Locale.ENGLISH))
|
||||
assertEquals("91%", formatDiscoverRating(DiscoverRating("rtCritics", 91.7), java.util.Locale.forLanguageTag("nl-NL")))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/** Feed projection must not lose titles when a contribution row is too short to display. */
|
||||
class DiscoverLaneProjectionTest {
|
||||
@Test fun discardedSeedGroupRemainsAvailableInCatalogue() {
|
||||
val titles = (1L..6L).map { DiscoverTitle(it, "movie", "Film $it") }
|
||||
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("Seed", titles.subList(3, 5))),
|
||||
lanes = listOf(DiscoverLane("trending", "Trending", titles)), status = DiscoverStatus.Ready)
|
||||
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
|
||||
assertEquals(listOf("pk-movie", "trending"), rows.map { it.id })
|
||||
assertEquals(titles.take(3), rows.first().titles)
|
||||
assertEquals(titles.drop(3), rows.last().titles)
|
||||
}
|
||||
|
||||
@Test fun catalogueDeduplicationKeepsMediaTypesDistinctAndHiddenPicksLast() {
|
||||
val movie = DiscoverTitle(1, "movie", "Film")
|
||||
val series = DiscoverTitle(1, "tv", "Series")
|
||||
val hidden = DiscoverTitle(2, "movie", "Hidden")
|
||||
val later = DiscoverTitle(3, "movie", "Later")
|
||||
val snapshot = DiscoverSnapshot(hidden = listOf(hidden), status = DiscoverStatus.Ready, lanes = listOf(
|
||||
DiscoverLane("first", "First", listOf(movie, series, hidden)),
|
||||
DiscoverLane("second", "Second", listOf(movie, series, later))))
|
||||
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", mapOf(hidden.key to hidden))
|
||||
assertEquals(listOf(listOf(movie, series), listOf(later), listOf(hidden)), rows.map { it.titles })
|
||||
assertEquals("hidden", rows.last().id)
|
||||
val filtered = projectDiscoverLanes(snapshot, emptyList(), "tv", mapOf(hidden.key to hidden))
|
||||
assertEquals(listOf(listOf(series)), filtered.map { it.titles })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
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 com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
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 approved Requests state uses the actual shell overlay at native television density. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class RequestsCaptureTest {
|
||||
@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, snapshot: ComingInSnapshot, person: String = "justin", expanded: Boolean = false) {
|
||||
val state = ShellState(fixturePeople(), person)
|
||||
state.pickerVisible = false
|
||||
state.enterDestination("settings")
|
||||
state.requests.update(snapshot)
|
||||
state.openRequests()
|
||||
if (expanded) { state.requests.key(ShellKey.Down); state.requests.key(ShellKey.Right) }
|
||||
rule.setContent {
|
||||
WolfskinTheme { ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) }
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"))
|
||||
directory.mkdirs()
|
||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
||||
}
|
||||
|
||||
@Test fun st081_empty() = capture("st-081", ComingInSnapshot(status = ComingInStatus.Ready, signedIn = true))
|
||||
@Test fun st082_stages() = capture("st-082", requestsFixture())
|
||||
@Test fun st083_unavailable() = capture("st-083", ComingInSnapshot())
|
||||
@Test fun st084_loading() = capture("st-084", ComingInSnapshot(status = ComingInStatus.Loading))
|
||||
@Test fun st085_partial() = capture("st-085", requestsFixture().copy(items = requestsFixture().items.take(3), partial = true))
|
||||
@Test fun st086_readiness() = capture("st-086", requestsFixture(), expanded = true)
|
||||
@Test fun st088_sharedActor() = capture("st-088", requestsFixture(shared = true), person = "shared")
|
||||
@Test fun st111_focusedRatings() = capture("st-111", requestsFixture(ratings = true))
|
||||
}
|
||||
|
||||
/** Shared actor and stage fixtures are projections, never network calls or synthetic UI redraws. */
|
||||
fun requestsFixture(shared: Boolean = false, ratings: Boolean = false): ComingInSnapshot {
|
||||
val source = RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests.json")!!.bufferedReader().use { it.readText() }
|
||||
val items = readComingInFixture(source) { name ->
|
||||
RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests/$name")!!.use { it.readBytes() }
|
||||
}
|
||||
val selected = when { shared -> items.filter { it.id == "x7" }; ratings -> items.filter { it.id == "x8" }; else -> items.take(6) }
|
||||
return ComingInSnapshot(selected, ComingInStatus.Ready, signedIn = true)
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import java.util.Locale
|
||||
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
|
||||
|
||||
/** Key replays exercise real shell handling, including trapped overlays and title-return memory. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class RequestsKeysTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
private fun mount(state: ShellState) {
|
||||
rule.setContent { WolfskinTheme { ShellSurface(state) } }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
|
||||
private fun send(first: Key, second: Key? = null, third: Key? = null, fourth: Key? = null) {
|
||||
listOfNotNull(first, second, third, fourth).forEach { key ->
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sheet(snapshot: ComingInSnapshot = requestsFixture(), actions: (ShellAction) -> Unit = {}): ShellState =
|
||||
ShellState(fixturePeople(), "justin", actions).apply {
|
||||
pickerVisible = false
|
||||
enterDestination("settings")
|
||||
requests.update(snapshot)
|
||||
openRequests()
|
||||
}
|
||||
|
||||
@Test fun approvedNinePressPathUsesRealShellFocus() {
|
||||
val paths = RequestsKeysTest::class.java.getResourceAsStream("/requests-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) }
|
||||
val keys = mapOf("OK" to Key.DirectionCenter, "UP" to Key.DirectionUp, "LEFT" to Key.DirectionLeft,
|
||||
"RIGHT" to Key.DirectionRight, "DOWN" to Key.DirectionDown)
|
||||
val state = ShellState(fixturePeople(), "justin")
|
||||
state.requests.update(requestsFixture())
|
||||
mount(state)
|
||||
val path = paths.getJSONObject(0)
|
||||
val presses = path.getJSONArray("keys")
|
||||
assertEquals(9, path.getInt("presses"))
|
||||
assertEquals(path.getInt("presses"), presses.length())
|
||||
for (index in 0 until presses.length()) send(keys.getValue(presses.getString(index)))
|
||||
rule.onNodeWithTag("coming-in").assertExists()
|
||||
rule.onNodeWithText("Requests · 5 open").assertExists()
|
||||
assertEquals("settings", state.route)
|
||||
assertEquals("x5", state.requests.focusedId)
|
||||
assertFalse(state.navFocused)
|
||||
}
|
||||
|
||||
@Test fun readinessWindowAndTitleRoundTripPreserveRequest() {
|
||||
lateinit var state: ShellState
|
||||
state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("title/${action.request.mediaType}:${action.request.tmdbId}") }
|
||||
mount(state)
|
||||
send(Key.DirectionDown, Key.DirectionRight)
|
||||
rule.onNodeWithText("Download · Downloading").assertExists()
|
||||
assertEquals(4, state.requests.windowSize)
|
||||
assertEquals("x1", state.requests.focusedId)
|
||||
send(Key.DirectionLeft)
|
||||
rule.onNodeWithText("Download · Downloading").assertDoesNotExist()
|
||||
send(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionDown)
|
||||
assertEquals("x6", state.requests.focusedId)
|
||||
assertEquals(1, state.requests.windowStart)
|
||||
send(Key.DirectionDown)
|
||||
assertEquals("x6", state.requests.focusedId)
|
||||
send(Key.DirectionCenter)
|
||||
assertFalse(state.requestsVisible)
|
||||
assertTrue(state.route.startsWith("title/"))
|
||||
send(Key.Back)
|
||||
rule.onNodeWithTag("coming-row-x6").assertExists()
|
||||
assertEquals("x6", state.requests.focusedId)
|
||||
assertTrue(state.requestsVisible)
|
||||
send(Key.Back)
|
||||
assertFalse(state.requestsVisible)
|
||||
assertEquals("settings", state.route)
|
||||
assertEquals(0, state.settingsRow)
|
||||
}
|
||||
|
||||
@Test fun emptyAndLoadingTrapDirectionsWhileUnavailableRetries() {
|
||||
var retries = 0
|
||||
val state = sheet(ComingInSnapshot(status = ComingInStatus.Loading)) { if (it == ShellAction.RetryRequests) retries++ }
|
||||
mount(state)
|
||||
send(Key.DirectionUp, Key.DirectionLeft)
|
||||
assertFalse(state.navFocused)
|
||||
assertTrue(state.requestsVisible)
|
||||
send(Key.DirectionCenter)
|
||||
assertFalse(state.requestsVisible)
|
||||
rule.runOnIdle { state.requests.update(ComingInSnapshot()); state.openRequests() }
|
||||
send(Key.DirectionCenter)
|
||||
assertEquals(1, retries)
|
||||
assertTrue(state.requestsVisible)
|
||||
rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) }
|
||||
send(Key.DirectionDown, Key.DirectionCenter)
|
||||
rule.onNodeWithText("Nothing coming in.").assertExists()
|
||||
assertTrue(state.requestsVisible)
|
||||
}
|
||||
|
||||
@Test fun cachedRatingsRenderInTheTelevisionLocale() {
|
||||
val original = Locale.getDefault()
|
||||
try {
|
||||
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
|
||||
val state = sheet(requestsFixture(ratings = true))
|
||||
kotlinx.coroutines.test.runTest { state.requests.awaitFocusedRatings() }
|
||||
mount(state)
|
||||
rule.onNodeWithText("7,6").assertExists()
|
||||
rule.onNodeWithText("87%").assertExists()
|
||||
rule.runOnIdle { state.requests.update(state.requests.snapshot.copy(signedIn = false)) }
|
||||
rule.onNodeWithText("7,6").assertDoesNotExist()
|
||||
rule.onNodeWithText("87%").assertDoesNotExist()
|
||||
} finally { Locale.setDefault(original) }
|
||||
}
|
||||
|
||||
@Test fun selectingARootAfterARequestTitleDiscardsTheSheetReturnLink() {
|
||||
lateinit var state: ShellState
|
||||
state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("title/${action.request.mediaType}:${action.request.tmdbId}") }
|
||||
mount(state)
|
||||
send(Key.DirectionCenter, Key.DirectionUp, Key.DirectionCenter)
|
||||
assertEquals("home", state.route)
|
||||
send(Key.Back)
|
||||
assertFalse("Root Back must focus navigation, not reopen the abandoned sheet", state.requestsVisible)
|
||||
assertTrue(state.navFocused)
|
||||
}
|
||||
|
||||
@Test fun refreshKeepsStableFocusAndSharedActorNeverBecomesYou() {
|
||||
val snapshot = requestsFixture()
|
||||
val state = sheet(snapshot)
|
||||
mount(state)
|
||||
send(Key.DirectionDown)
|
||||
rule.runOnIdle { state.requests.update(snapshot.copy(items = snapshot.items.reversed(), partial = true)) }
|
||||
assertEquals("x1", state.requests.focusedId)
|
||||
rule.onNodeWithText("Some requests may be missing: Conductarr answered only in part.").assertExists()
|
||||
val shared = requestsFixture(shared = true).items.single()
|
||||
val viewer = fixturePeople().single { it.key == "shared" }
|
||||
assertEquals("Shared", shared.actor(viewer))
|
||||
assertEquals("Justin", shared.recipient(viewer))
|
||||
assertEquals(5, snapshot.openCount)
|
||||
assertEquals(listOf(0, 1, 1, 2, 3, 4), snapshot.ordered.map { it.stage.rank })
|
||||
}
|
||||
}
|
||||
92
wolfskin-design/src/test/resources/discover-keys.json
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{
|
||||
"pressPaths": [
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"goal": "Request a pick for yourself",
|
||||
"keys": [
|
||||
"OK",
|
||||
"UP",
|
||||
"RIGHT",
|
||||
"OK",
|
||||
"OK",
|
||||
"OK",
|
||||
"OK"
|
||||
],
|
||||
"presses": 7,
|
||||
"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": "Request a pick for Mindel",
|
||||
"keys": [
|
||||
"OK",
|
||||
"UP",
|
||||
"RIGHT",
|
||||
"OK",
|
||||
"OK",
|
||||
"RIGHT",
|
||||
"OK",
|
||||
"OK",
|
||||
"OK"
|
||||
],
|
||||
"presses": 9,
|
||||
"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 · Not interested on a pick (Discover hero)",
|
||||
"keys": [
|
||||
"OK",
|
||||
"UP",
|
||||
"RIGHT",
|
||||
"OK",
|
||||
"OK",
|
||||
"RIGHT",
|
||||
"RIGHT",
|
||||
"OK"
|
||||
],
|
||||
"presses": 8,
|
||||
"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 4 · Discover pick nobody holds → its Trailer (sample hand-off)",
|
||||
"keys": [
|
||||
"OK",
|
||||
"UP",
|
||||
"RIGHT",
|
||||
"OK",
|
||||
"OK",
|
||||
"RIGHT",
|
||||
"RIGHT",
|
||||
"RIGHT",
|
||||
"OK"
|
||||
],
|
||||
"presses": 9,
|
||||
"note": "4 on the title page (3 Rights to Trailer, OK); The Amazing Spider-Man",
|
||||
"start": "app launch: Who's watching with Justin focused (index.html:357)",
|
||||
"replayed": "PASS",
|
||||
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05",
|
||||
"requires": "Conductarr trailer field (spec 3.2, fifth addition); the press path runs once it lands"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
wolfskin-design/src/test/resources/requests-press-paths.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[
|
||||
{
|
||||
"goal": "Round 4 \u00b7 Home hero \u2192 Requests (Coming in) in Settings",
|
||||
"keys": [
|
||||
"OK",
|
||||
"UP",
|
||||
"LEFT",
|
||||
"OK",
|
||||
"RIGHT",
|
||||
"RIGHT",
|
||||
"DOWN",
|
||||
"OK",
|
||||
"OK"
|
||||
],
|
||||
"presses": 9,
|
||||
"note": "8 from the hero",
|
||||
"start": "app launch: Who's watching with Justin focused (index.html:357)",
|
||||
"replayed": "PASS",
|
||||
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
|
||||
}
|
||||
]
|
||||
217
wolfskin-design/src/test/resources/wolfskin-requests.json
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
{
|
||||
"source": "Locked Opus fixture/fixture.json; index.html EXAMPLES() pool order",
|
||||
"items": [
|
||||
{
|
||||
"id": "x1",
|
||||
"mediaType": "tv",
|
||||
"tmdbId": 11821,
|
||||
"title": "The Abbott and Costello Show",
|
||||
"requesterKey": "justin",
|
||||
"requesterName": "Justin",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Downloading",
|
||||
"poster": "tv-the-abbott-and-costello-show-1952-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "tmdb",
|
||||
"score": 7.2,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x2",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 13483,
|
||||
"title": "Awake",
|
||||
"requesterKey": "mindel",
|
||||
"requesterName": "Mindel",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "AlmostReady",
|
||||
"poster": "movie-awake-2007-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "rotten",
|
||||
"score": 23,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "spilled",
|
||||
"score": 55,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 6.5,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x3",
|
||||
"mediaType": "tv",
|
||||
"tmdbId": 70785,
|
||||
"title": "Anne with an E",
|
||||
"requesterKey": "shared",
|
||||
"requesterName": "Shared",
|
||||
"actorKey": "mindel",
|
||||
"actorName": "Mindel",
|
||||
"stage": "Searching",
|
||||
"poster": "tv-anne-with-an-e-2017-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 63,
|
||||
"percentage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x4",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 99861,
|
||||
"title": "Avengers: Age of Ultron",
|
||||
"requesterKey": "justin",
|
||||
"requesterName": "Justin",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Waiting",
|
||||
"poster": "movie-avengers-age-of-ultron-2015-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 75,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 82,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 7.3,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x5",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 1033148,
|
||||
"title": "Die My Love",
|
||||
"requesterKey": "mindel",
|
||||
"requesterName": "Mindel",
|
||||
"actorKey": "mindel",
|
||||
"actorName": "Mindel",
|
||||
"stage": "Failed",
|
||||
"poster": "movie-die-my-love-2025-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 74,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "spilled",
|
||||
"score": 45,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 6,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x6",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 416153,
|
||||
"title": "Arctic Dogs",
|
||||
"requesterKey": "shared",
|
||||
"requesterName": "Shared",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Ready",
|
||||
"poster": "movie-arctic-dogs-2019-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "rotten",
|
||||
"score": 13,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 60,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 4.9,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x7",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 76203,
|
||||
"title": "12 Years a Slave",
|
||||
"requesterKey": "justin",
|
||||
"requesterName": "Justin",
|
||||
"actorKey": "shared",
|
||||
"actorName": "Shared",
|
||||
"stage": "Waiting",
|
||||
"poster": "movie-12-years-a-slave-2013-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 95,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 90,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 8.1,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "x8",
|
||||
"mediaType": "movie",
|
||||
"tmdbId": 14069,
|
||||
"title": "The Girl Who Leapt Through Time",
|
||||
"requesterKey": "mindel",
|
||||
"requesterName": "Mindel",
|
||||
"actorKey": "justin",
|
||||
"actorName": "Justin",
|
||||
"stage": "Waiting",
|
||||
"poster": "movie-the-girl-who-leapt-through-time-2006-poster.jpg",
|
||||
"ratings": [
|
||||
{
|
||||
"mark": "fresh",
|
||||
"score": 87,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "upright",
|
||||
"score": 90,
|
||||
"percentage": true
|
||||
},
|
||||
{
|
||||
"mark": "imdb",
|
||||
"score": 7.6,
|
||||
"percentage": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |