Build the Discover catalogue and request flow
This commit is contained in:
parent
9be55c570a
commit
6a02887dc9
664 changed files with 2103 additions and 8 deletions
|
|
@ -0,0 +1,83 @@
|
|||
// 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.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
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.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", configure: (DiscoverState) -> Unit = {}) {
|
||||
val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination("discover") }
|
||||
val snapshot = 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, BitmapPainter>()
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
CompositionLocalProvider(LocalDiscoverPainter provides { path -> painters.getOrPut(path) { BitmapPainter(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(System.getProperty("wolfskin.captureDirectory")).apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
||||
}
|
||||
|
||||
@Test fun st029_discover() = capture("st-029")
|
||||
}
|
||||
|
||||
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,140 @@
|
|||
// 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.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).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 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)
|
||||
}
|
||||
}
|
||||
92
wolfskin-design/src/test/resources/discover-keys.json
Normal file
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue