Integrate reviewed Requests and Discover surfaces
Some checks failed
review/design-claude Changes requested: 13 blocking design/behaviour findings (see PR comment)

This commit is contained in:
justinzeus 2026-11-05 20:46:58 +01:00
commit e243894a20
730 changed files with 4496 additions and 92 deletions

View file

@ -0,0 +1,101 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import kotlinx.coroutines.CompletableDeferred
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.DiscoverFixture
import org.justinvisser.wolfskin.design.DiscoverScreen
import org.justinvisser.wolfskin.design.DiscoverState
import org.justinvisser.wolfskin.design.DiscoverStatus
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.ShellSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** The real asynchronous host must return focus from its loading nav to the first loaded action. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverLoadingFocusTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun firstOkAfterLoadingOpensTheHeroInsteadOfSelectingTheNav() {
val pending = CompletableDeferred<DiscoverFixture>()
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
pickerVisible = false
enterDestination("discover")
}
lateinit var current: DiscoverState
rule.setContent {
val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, loadFixture = { pending.await() })
current = state
WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } }
}
rule.runOnIdle {
assertEquals(DiscoverStatus.Loading, current.snapshot.status)
assertTrue(shell.navFocused)
val json = rule.activity.assets.open("wolfskin-discover/fixture.json").bufferedReader().use { it.readText() }
pending.complete(DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" })
}
rule.waitForIdle()
rule.runOnIdle {
assertEquals(DiscoverStatus.Ready, current.snapshot.status)
assertFalse(shell.navFocused)
assertEquals(0, current.focusedRow)
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
rule.runOnIdle {
assertNotNull(current.title)
assertEquals("The Amazing Spider-Man", current.title!!.title)
assertEquals("discover-title/${current.title!!.key}", shell.route)
}
}
@Test fun finishingLoadPreservesANavTargetChosenWhileLoading() {
val pending = CompletableDeferred<DiscoverFixture>()
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
pickerVisible = false
enterDestination("discover")
}
lateinit var current: DiscoverState
rule.setContent {
val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, loadFixture = { pending.await() })
current = state
WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } }
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight) }
rule.runOnIdle {
assertEquals(DiscoverStatus.Loading, current.snapshot.status)
assertEquals(2, shell.navIndex)
val json = rule.activity.assets.open("wolfskin-discover/fixture.json").bufferedReader().use { it.readText() }
pending.complete(DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" })
}
rule.waitForIdle()
rule.runOnIdle {
assertEquals(DiscoverStatus.Ready, current.snapshot.status)
assertTrue(shell.navFocused)
assertEquals(2, shell.navIndex)
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
rule.runOnIdle {
assertEquals("movies", shell.route)
assertEquals(null, current.title)
}
}
}

View file

@ -0,0 +1,52 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
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.assertEquals
import org.junit.Test
import org.justinvisser.wolfskin.design.ComingInRequest
import org.justinvisser.wolfskin.design.ComingInSnapshot
import org.justinvisser.wolfskin.design.ComingInStage
import org.justinvisser.wolfskin.design.ComingInStatus
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
/** The fake clock checks refresh boundaries and cancellation without a server or a real wait. */
@OptIn(ExperimentalCoroutinesApi::class)
class RequestsRefreshTest {
@Test fun foregroundOpenUnsettledSettledAndStoppedTransitions() = runTest {
val state = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin")
val request = ComingInRequest("one", "movie", 1, "One", "justin", "Justin", "shared", "Shared", ComingInStage.Waiting)
state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready, signedIn = true))
var reads = 0
val active = launch { refreshRequestsWhileStarted(state) { reads++ } }
runCurrent()
assertEquals(1, reads)
advanceTimeBy(30_000); runCurrent()
assertEquals("Closed and settled does not poll", 1, reads)
state.openRequests()
advanceTimeBy(29_999); runCurrent()
assertEquals(1, reads)
advanceTimeBy(1); runCurrent()
assertEquals("An open empty sheet still refreshes at 30 seconds", 2, reads)
state.closeRequests()
state.requests.update(ComingInSnapshot(listOf(request), ComingInStatus.Ready, signedIn = true))
advanceTimeBy(30_000); runCurrent()
assertEquals("An unsettled request refreshes after the sheet closes", 3, reads)
state.requests.update(ComingInSnapshot(listOf(request.copy(stage = ComingInStage.Ready)), ComingInStatus.Ready, signedIn = true))
advanceTimeBy(30_000); runCurrent()
assertEquals("Ready requests stop polling", 3, reads)
active.cancel(); runCurrent()
state.openRequests()
advanceTimeBy(60_000); runCurrent()
assertEquals("A stopped lifecycle cannot poll", 3, reads)
val resumed = launch { refreshRequestsWhileStarted(state) { reads++ } }
runCurrent()
assertEquals("Returning to the foreground refreshes immediately", 4, reads)
resumed.cancel()
}
}