Build the Discover catalogue and request flow

This commit is contained in:
justinzeus 2026-11-05 16:18:19 +01:00
parent 9be55c570a
commit 6a02887dc9
664 changed files with 2103 additions and 8 deletions

View file

@ -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
}

View file

@ -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)
}
}

View 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