Add TV offline recovery and per-person state isolation
All checks were successful
review/design-claude Claude Sonnet cross-family review: pass, no blockers

This commit is contained in:
justinzeus 2026-11-05 22:11:00 +01:00
parent 9dd321f07e
commit a01c0a78fb
29 changed files with 783 additions and 86 deletions

View file

@ -0,0 +1,197 @@
// 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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.text.TextLayoutResult
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Before
import org.junit.After
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
/** Native state proof uses only packaged fixtures and records the actual text/font/layout surface. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class RecoveryCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val previousLocale = Locale.getDefault()
@Before fun fullscreen() {
Locale.setDefault(Locale.US)
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
}
@After fun restoreLocale() { Locale.setDefault(previousLocale) }
private fun shell(route: String = "home") = ShellState(fixturePeople(), "mindel").apply { pickerVisible = false; enterDestination(route) }
private fun mount(shell: ShellState, overlay: @Composable () -> Unit = {}, content: @Composable (Modifier) -> Unit) {
rule.setContent { WolfskinTheme {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = overlay, content = content)
} }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
}
private fun capture(name: String) {
val directory = File(System.getProperty("wolfskin.captureDirectory"), "recovery").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
val text = JSONArray()
val forbidden = Regex("Recs Movies|Recs Shows|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page [0-9]")
rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
val layouts = mutableListOf<TextLayoutResult>()
node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
layouts.forEach { layout ->
val words = layout.layoutInput.text.text
val style = layout.layoutInput.style
assertTrue("Text smaller than 12sp: $words", style.fontSize.value >= 12f)
assertFalse("Internal wording: $words", forbidden.containsMatchIn(words))
text.put(JSONObject().put("text", words).put("fontSizeSp", style.fontSize.value).put("weight", style.fontWeight?.weight)
.put("lineCount", layout.lineCount).put("lineHeightSp", style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL)
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height))
}
}
File(directory, "$name-layout.json").writeText(text.toString(2))
}
@Test fun st090_offlinePlayToastRetainsHomeAndFocusedResume() {
val shell = shell()
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
shell.toast = RecoveryMessage.PlaybackUnavailable
mount(shell) { HomeScreen(shell, snapshot, it) {} }
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
rule.onNodeWithText("Resume S4 E9").assertIsDisplayed()
assertFalse(shell.navFocused)
val toast = rule.onNodeWithTag("recovery-toast").fetchSemanticsNode().boundsInRoot
val message = rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).fetchSemanticsNode().boundsInRoot
assertEquals(1064f, toast.left, 1f)
assertEquals(150f, toast.top, 1f)
assertEquals(760f, toast.width, 1f)
assertEquals(1136f, message.left, 1f)
capture("st-090")
}
@Test fun savedLibraryHasNavAndHeaderChipAndAReachableRetryTail() {
var retried = false
val shell = shell("movies")
val grid = LibraryGridState("Movies", listOf(LibraryTitle("saved", "A saved film", "movie")), onRecover = { retried = true }).apply {
savedAt = "20:42"; tail = GridTail.Retry
}
mount(shell) { LibraryScreen(shell, grid, it) }
assertEquals(2, rule.onAllNodesWithText("Saved 20:42").fetchSemanticsNodes().size)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
assertTrue(retried)
capture("library-saved")
}
@Test fun libraryWithoutSavedDataOffersRecovery() {
var retried = false
val shell = shell("shows")
val grid = LibraryGridState("Shows", emptyList(), onRecover = { retried = true }).apply { status = LibraryStatus.Unavailable }
mount(shell) { LibraryScreen(shell, grid, it) }
rule.onNodeWithText("Try again").assertIsDisplayed()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
assertTrue(retried)
capture("library-unavailable")
}
@Test fun savedDiscoverKeepsHeroAndRecoveryWithoutAllowingRequests() {
val shell = shell("discover")
val snapshot = discoverTestFixture().snapshot("mindel").copy(savedAt = "20:42")
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(DiscoverSnapshot(status = DiscoverStatus.Unavailable), "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
mount(shell, overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) }
rule.onNodeWithText("Saved 20:42").assertIsDisplayed()
assertTrue(state.hasHero)
assertFalse(state.canRequest)
rule.runOnIdle { state.focus(state.rowCount - 1) }
rule.mainClock.advanceTimeBy(1000)
rule.onNodeWithText("More titles couldn’t load").assertIsDisplayed()
capture("discover-saved")
}
@Test fun unavailableDiscoverRetryShowsExactFailureToast() {
val shell = shell("discover")
val snapshot = DiscoverSnapshot(status = DiscoverStatus.Unavailable)
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
mount(shell, overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) }
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
rule.onNodeWithText(RecoveryMessage.StillUnreachable).assertIsDisplayed()
capture("discover-unavailable-retry")
}
@Test fun savedRequestsKeepTheirRowsAndRecoveryAfterFailure() {
val shell = shell("settings")
val item = ComingInRequest("saved", "movie", 1, "A saved request", "mindel", "Mindel", "shared", "Shared", ComingInStage.Waiting)
shell.requests.update(ComingInSnapshot(listOf(item), ComingInStatus.Ready, signedIn = true, savedAt = "20:42"))
shell.requests.update(ComingInSnapshot(status = ComingInStatus.Unavailable, signedIn = true))
shell.openRequests()
mount(shell) { SettingsList(shell, it) }
rule.onNodeWithText("A saved request").assertIsDisplayed()
rule.onNodeWithText("Try again").assertIsDisplayed()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) }
assertTrue(shell.requests.recoveryFocused)
capture("requests-saved")
}
@Test fun failedPosterReadUsesTheSameMissingArtFallbackAsHome() {
val title = LibraryTitle("missing", "A missing film", "movie", poster = "file:///missing-offline-poster.jpg")
mount(shell("movies")) { LibraryScreen(shell("movies"), LibraryGridState("Movies", listOf(title)), it) }
rule.waitUntil(5000) { rule.onAllNodesWithText(title.title).fetchSemanticsNodes().size >= 2 }
capture("library-missing-art")
}
@Test fun dutchLibraryScoreRendersCommaWithoutBreakingThePlate() {
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
val title = LibraryTitle("dutch", "A film", "movie", ratings = listOf(LibraryRating("tmdb", 7.8)))
val shell = shell("movies")
mount(shell) { LibraryScreen(shell, LibraryGridState("Movies", listOf(title)), it, ratingsSignedIn = true) }
rule.onNodeWithText("7,8").assertIsDisplayed()
capture("library-dutch-score")
}
@Config(qualifiers = "nl-rNL-w960dp-h540dp-xhdpi")
@Test fun dutchDiscoverHeroScoreRendersComma() {
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
val title = DiscoverTitle(1, "movie", "A film", ratings = listOf(DiscoverRating("tmdb", 7.8)))
val snapshot = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready)
val shell = shell("discover")
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
mount(shell) { DiscoverScreen(state, it) }
rule.onNodeWithText("7,8").assertIsDisplayed()
capture("discover-dutch-score")
}
@Test fun failedDiscoverBackdropUsesTheSharedAmbientFallback() {
val shell = shell("discover")
val title = DiscoverTitle(1, "movie", "A film", backdrop = "file:///missing-offline-backdrop.jpg")
val snapshot = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready)
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
mount(shell) { DiscoverScreen(state, it) }
rule.waitUntil(5000) { rule.onAllNodesWithTag("discover-missing-art").fetchSemanticsNodes().isNotEmpty() }
rule.onNodeWithTag("discover-missing-art").assertIsDisplayed()
capture("discover-missing-art")
}
}

View file

@ -0,0 +1,117 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Failed refresh, loading focus and identity changes must never discard or leak a saved view. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class RecoveryStateTest {
@Test fun failedDiscoverRefreshKeepsTitlesAndFocusButDisablesWritesUntilRecovery() = runTest {
val title = DiscoverTitle(1, "movie", "Saved film")
val initial = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready)
var response = DiscoverSnapshot(status = DiscoverStatus.Unavailable)
var writes = 0
val operations = object : DiscoverOperations {
override suspend fun refresh() = response
override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { writes++; return DiscoverRequestResult(RequestPhase.Receipt) }
override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed)
override suspend fun hide(profile: String, title: DiscoverTitle): Boolean { writes++; return true }
override suspend fun restore(profile: String, title: DiscoverTitle): Boolean { writes++; return true }
}
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
val state = DiscoverState(shell, initial, operations, backgroundScope, nowMillis = { 1000L })
state.focus(0)
state.refresh()
testScheduler.runCurrent()
assertEquals(listOf(title), state.heroes)
assertEquals(0, state.focusedRow)
assertNotNull(state.snapshot.savedAt)
assertFalse(state.canRequest)
assertEquals(RecoveryMessage.StillUnreachable, state.toast)
state.hide(title)
testScheduler.runCurrent()
assertEquals(0, writes)
val recovery = state.lanes.last()
state.focus(state.lanes.indexOf(recovery) + state.firstShelf)
assertEquals(DiscoverTail.Retry, state.focusedLane?.tail)
response = initial
state.key(ShellKey.Ok)
testScheduler.runCurrent()
assertTrue(state.canRequest)
assertNull(state.snapshot.savedAt)
assertNull(state.toast)
}
@Test fun revokedDiscoverIdentityDropsSavedTitlesInsteadOfPaintingThemWhileSignedOut() = runTest {
val initial = discoverTestFixture().snapshot("justin")
val operations = RecordingDiscoverOperations(DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn), "Justin")
val state = DiscoverState(ShellState(fixturePeople(), "justin"), initial, operations, backgroundScope)
state.refresh()
testScheduler.runCurrent()
assertFalse(state.isSignedIn)
assertTrue(state.heroes.isEmpty())
assertTrue(state.lanes.isEmpty())
}
@Test fun DownCannotLeaveNavForSkeletonOnlyHomeLibraryOrDiscover() = runTest {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val loading = LibraryGridState("Movies", emptyList()).apply { status = LibraryStatus.Loading }
val home = HomeFocus(shell, buildHomeRows(HomeLibrary(), emptyList(), loading = true)) {}
val discover = DiscoverState(shell, DiscoverSnapshot(status = DiscoverStatus.Loading), RecordingDiscoverOperations(DiscoverSnapshot(), "Justin"), backgroundScope)
for (content in listOf(loading, home, discover)) {
shell.contentFocus = content
shell.focusNav(shell.currentTab)
shell.key(ShellKey.Down)
assertTrue("Skeleton-only content must retain nav focus", shell.navFocused)
}
loading.status = LibraryStatus.Unavailable
shell.contentFocus = loading
shell.key(ShellKey.Down)
assertFalse("A recovery button is reachable once loading fails", shell.navFocused)
}
@Test fun requestsKeepSavedRowsOnFailureAndRecoveryNeverOpensTheLastTitle() {
var retried = false
var opened = false
val shell = ShellState(fixturePeople(), "justin") {
if (it == ShellAction.RetryRequests) retried = true
if (it is ShellAction.RequestTitle) opened = true
}.apply { pickerVisible = false; enterDestination("settings") }
val request = ComingInRequest("a", "movie", 1, "Saved film", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting)
shell.requests.update(ComingInSnapshot(listOf(request), ComingInStatus.Ready, signedIn = true, savedAt = "20:42"))
shell.requests.update(ComingInSnapshot(status = ComingInStatus.Unavailable, signedIn = true))
shell.openRequests()
assertEquals(listOf(request), shell.requests.snapshot.items)
assertEquals("20:42", shell.navSavedAt)
shell.key(ShellKey.Down)
assertTrue(shell.requests.recoveryFocused)
shell.key(ShellKey.Ok)
assertTrue(retried)
assertFalse(opened)
shell.key(ShellKey.Up)
assertFalse(shell.requests.recoveryFocused)
shell.key(ShellKey.Back)
assertFalse(shell.requestsVisible)
}
@Test fun switchingPeopleNeverShowsAnotherPersonsCachedRequestsOrSavedChip() {
val shell = ShellState(fixturePeople(), "justin")
val item = ComingInRequest("private", "movie", 1, "Person A film", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting)
shell.requests.update(ComingInSnapshot(listOf(item), ComingInStatus.Ready, signedIn = true, savedAt = "20:42"))
val original = shell.requests
shell.pickerIndex = 1
shell.key(ShellKey.Ok)
shell.openRequests()
assertEquals("mindel", shell.person.key)
assertTrue(shell.requests.snapshot.items.isEmpty())
assertNull(shell.navSavedAt)
assertNotSame(original, shell.requests)
}
}