Integrate Requests with the shared image cache

This commit is contained in:
justinzeus 2026-11-05 20:20:06 +01:00
commit 4cd75cfa26
60 changed files with 1648 additions and 29 deletions

View file

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

View file

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

View file

@ -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("request-title/${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("request-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("request-title/${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 })
}
}

View 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"
}
]

View 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
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB