Keep loaded title refreshes stable and guard request identities
All checks were successful
review/code-claude Code APPROVE: no stale data, request guard recovers via toast
review/design-claude Design PASS: no look change beyond flash removal

This commit is contained in:
justinzeus 2026-11-07 22:16:30 +01:00
parent b51dced5fe
commit 5f759b8fef
11 changed files with 305 additions and 39 deletions

View file

@ -85,4 +85,36 @@ class ConductarrTitlePagesTest {
assertTrue(emptyList<LibraryRating>().withEpisode(8.0, null).isEmpty())
assertTrue(emptyList<LibraryRating>().withEpisode(8.0, 0).isEmpty())
}
@Test fun requestIdentityUsesTheTitleRootNotTheEpisodeBeingWatched() {
val viewer = ShellPerson("shared", "Shared", "S", true)
val episode = TitleItem("episode-id", "Episode", "episode", tmdbId = 999)
val series = TitlePageData(TitleItem("series-id", "Series", "tv", held = true, tmdbId = 44),
watchTarget = episode, seasonNumbers = listOf(0, 1, 2))
val request = requireNotNull(series.requestTitle(viewer))
assertEquals(44L, request.tmdb)
assertEquals("tv", request.type)
assertEquals(listOf(1, 2), request.seasons)
assertEquals(setOf("shared"), request.heldBy)
val catalogue = TitlePageData(TitleItem("movie:42", "Film", "movie")).requestTitle(viewer)
assertEquals(42L, catalogue?.tmdb)
assertEquals("movie", catalogue?.type)
}
@Test fun incompleteOrNonCatalogueRootsCannotBecomeRequestsForAnotherTitle() {
val viewer = ShellPerson("shared", "Shared", "S", true)
val invalid = listOf(
TitleItem("fixture-slug", "Unknown", "movie"),
TitleItem("fixture-slug", "Unknown", "movie", tmdbId = 0),
TitleItem("fixture-slug", "Unknown", "tv", tmdbId = -1),
TitleItem("tv:42", "Wrong media", "movie"),
TitleItem("42", "Not a catalogue route", "tv"),
TitleItem("movie:0", "Zero", "movie"),
TitleItem("tv:-42", "Negative", "tv"),
TitleItem("movie:42:1", "Malformed", "movie"),
TitleItem("episode-id", "Orphan episode", "episode", tmdbId = 999),
TitleItem("person:216", "Person", "person", tmdbId = 216),
)
invalid.forEach { item -> assertNull(item.toString(), TitlePageData(item).requestTitle(viewer)) }
}
}

View file

@ -22,6 +22,7 @@ import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.mockk
import java.io.File
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.*
@ -50,6 +51,7 @@ class ConductarrTitleSurfaceTest {
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true))
val revokedLookup = CompletableDeferred<java.util.UUID?>()
try {
val route = titleRoute("movie:42")
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply {
@ -93,16 +95,26 @@ class ConductarrTitleSurfaceTest {
assertFalse(body.containsKey("actor"))
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-receipt.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
rule.mainClock.autoAdvance = false
coEvery { facade.findHeld(any(), any()) } coAnswers { revokedLookup.await() }
fake.ratingsStatus = 401
runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) }
rule.waitUntil(30_000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
rule.mainClock.advanceTimeByFrame()
viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable
}
repeat(12) { index ->
rule.mainClock.advanceTimeBy(16)
assertEquals("Revoking access must clear private facts without replaying the first-load skeleton",
TitleReadStatus.Unavailable, viewModel.title!!.data.status)
if (index == 0 || index == 11) rule.onRoot().captureRoboImage(File(directory, "conductarr-title-revoked-$index.png").absolutePath)
}
revokedLookup.complete(null)
assertTrue(viewModel.title!!.data.item.ratings.isEmpty())
assertFalse(viewModel.title!!.options.any { it.id == "request" })
assertFalse(viewModel.title!!.menuOptions.any { it.id == "add" })
} finally { viewModel.viewModelScope.cancel() }
} finally { viewModel.viewModelScope.cancel(); revokedLookup.complete(null) }
}
}
}

View file

@ -13,6 +13,7 @@ import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.preferences.AppPreferences
@ -45,10 +46,6 @@ class DiscoverRefreshMotionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun loadedCatalogueRefreshKeepsReadyActionsOpaqueAndStationary() {
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
rule.mainClock.autoAdvance = false
FakeConductarr().use { fake ->
fake.localArt = true
ConductarrSession(MemoryCacheStore()).use { session ->
@ -61,17 +58,9 @@ class DiscoverRefreshMotionTest {
val route = titleRoute("tv:44")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
val refreshLookup = CompletableDeferred<java.util.UUID?>()
try {
rule.setContent { WolfskinTheme {
model.title?.let { state ->
val bitmaps = listOfNotNull(state.data.item.poster, state.data.item.backdrop).associateWith { art }
CompositionLocalProvider(LocalTitleBitmaps provides bitmaps) {
TitleSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
}
}
} }
render(model, shell)
rule.runOnIdle { model.load(route, person, preferences, fixtures = false) }
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
@ -102,6 +91,119 @@ class DiscoverRefreshMotionTest {
}
}
@Test fun loadedFixtureRefreshKeepsTheSameReadyHeaderAndCatalogueIdentity() {
ConductarrSession(MemoryCacheStore()).use { session ->
val model = WolfskinTitleViewModel(rule.activity, mockk(relaxed = true), TitleFixtureProvider(rule.activity),
NavigationManager(), session, mockk(relaxed = true))
val person = ShellPerson("justin", "Justin", "J", true)
val route = titleRoute("movie-the-amazing-spider-man-2012")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
try {
render(model, shell)
rule.runOnIdle { model.load(route, person, preferences, fixtures = true) }
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.status == TitleReadStatus.Ready
}
rule.mainClock.advanceTimeBy(640)
val loaded = model.title!!
val request = requireNotNull(loaded.data.requestTitle(person))
assertEquals(1930L, request.tmdb)
assertEquals("movie", request.type)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-fixture-refresh").apply { mkdirs() }
val bounds = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt())
val baseline = brightness(File(directory, "loaded.png"), bounds, pixels)
val refreshedWatched = !loaded.data.watchTarget.watched
rule.activity.getSharedPreferences("wolfskin-title-fixture", android.content.Context.MODE_PRIVATE).edit()
.putBoolean("${person.key}:${loaded.data.watchTarget.id}:watched", refreshedWatched).commit()
rule.runOnIdle {
model.load(route, person, preferences, fixtures = true, force = true)
assertEquals("A fixture foreground refresh cannot reinstate Loading", TitleReadStatus.Ready, model.title!!.data.status)
}
repeat(12) { index ->
rule.mainClock.advanceTimeBy(16)
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
assertFalse(shell.navFocused)
assertEquals(bounds, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
assertEquals(baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels))
}
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.watchTarget?.watched == refreshedWatched
}
assertEquals(refreshedWatched, model.title!!.watched)
} finally { model.viewModelScope.cancel() }
}
}
@Test fun loadedHeldRefreshKeepsTheHeaderVisibleWhilePlaybackIsRevalidated() {
ConductarrSession(MemoryCacheStore()).use { session ->
val id = java.util.UUID.fromString("00000000-0000-0000-0000-000000000042")
val held = TitlePageData(TitleItem(id.toString(), "Held film", "movie", held = true))
val facade = mockk<TitleReadFacade>()
coEvery { facade.readSaved(any(), any()) } returns null
coEvery { facade.read(any(), any(), any()) } returns held
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true))
val person = ShellPerson("shared", "Shared", "S", false)
val route = titleRoute(id.toString())
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination(route) }
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
val refresh = CompletableDeferred<TitlePageData>()
try {
render(model, shell)
rule.runOnIdle { model.load(route, person, preferences, fixtures = false) }
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.status == TitleReadStatus.Ready
}
rule.mainClock.advanceTimeBy(640)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-held-refresh").apply { mkdirs() }
val bounds = rule.onNodeWithText("Held film").fetchSemanticsNode().boundsInRoot
val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt())
val baseline = brightness(File(directory, "loaded.png"), bounds, pixels)
coEvery { facade.read(any(), any(), any()) } coAnswers { refresh.await() }
rule.runOnIdle {
model.load(route, person, preferences, fixtures = false, force = true)
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
assertFalse(model.title!!.data.watchReady)
}
repeat(12) { index ->
rule.mainClock.advanceTimeBy(16)
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
assertEquals(bounds, rule.onNodeWithText("Held film").fetchSemanticsNode().boundsInRoot)
assertEquals(baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels))
}
refresh.complete(held)
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.watchReady == true
}
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
assertTrue(model.title!!.options.any { it.id == "play" })
} finally { model.viewModelScope.cancel(); refresh.complete(held) }
}
}
private fun render(model: WolfskinTitleViewModel, shell: ShellState) {
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
rule.mainClock.autoAdvance = false
val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap()
rule.setContent { WolfskinTheme {
model.title?.let { state ->
val paths = listOfNotNull(state.data.item.poster, state.data.item.backdrop) +
state.data.cast.mapNotNull { it.portrait } +
(state.data.related + state.data.episodes).flatMap { listOfNotNull(it.poster, it.backdrop) }
CompositionLocalProvider(LocalTitleBitmaps provides paths.associateWith { art }) {
TitleSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
}
}
} }
}
private fun brightness(file: File, bounds: Rect, pixels: IntArray): Long {
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)

View file

@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
/** Even an incomplete/stale requestable page must recover through Wolfskin, not throw. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleRequestSafetyTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun missingCatalogueIdentityShowsRecoveryAndCannotProduceAFakeHideReceipt() {
FakeConductarr().use { fake ->
ConductarrSession(MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true))
val person = ShellPerson("shared", "Shared", "S", true)
val route = titleRoute("movie:42")
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) }
val art = ImageBitmap(1, 1)
try {
rule.setContent { WolfskinTheme {
val data = model.title?.data
val paths = listOfNotNull(data?.item?.poster, data?.item?.backdrop) +
data?.cast.orEmpty().mapNotNull { it.portrait } +
data?.related.orEmpty().flatMap { listOfNotNull(it.poster, it.backdrop) }
CompositionLocalProvider(LocalTitleBitmaps provides paths.associateWith { art }) {
TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, fixtures = false)
}
} }
rule.waitUntil(30_000) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.connection == TitleConnection.SignedIn && model.conductarrAction != null
}
// A stale/partial read must not make the action adapter throw even if its
// previous requestable snapshot still exists.
rule.runOnIdle {
val current = model.title!!
current.update(current.data.copy(item = TitleItem("missing-identity", "Unidentified film", "movie"), pick = true))
}
rule.onNodeWithText("Request").performClick()
rule.onNodeWithText(RecoveryMessage.TitleActionUnavailable).assertIsDisplayed()
rule.onNodeWithText("Unidentified film").assertIsDisplayed()
assertFalse(shell.navFocused)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "title-request-safety").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "missing-identity-request.png").absolutePath)
rule.onNodeWithText("Not interested").performClick()
assertFalse(model.title!!.data.hidden)
assertNull(model.title!!.sheet)
rule.onNodeWithText(RecoveryMessage.TitleActionUnavailable).assertIsDisplayed()
rule.onRoot().captureRoboImage(File(directory, "missing-identity-hide.png").absolutePath)
} finally { model.viewModelScope.cancel() }
}
}
}
}