Integrate shared offline recovery and saved-state isolation
All checks were successful
review/design-claude 13 fixes passed Sonnet re-review at cd625b42; merged head 84453040 = main merge + shared RecoveryToast reuse: 33/34 pairs byte-identical, st-048 adopts the shared toast, 830 tests green (firstmate check)
All checks were successful
review/design-claude 13 fixes passed Sonnet re-review at cd625b42; merged head 84453040 = main merge + shared RecoveryToast reuse: 33/34 pairs byte-identical, st-048 adopts the shared toast, 830 tests green (firstmate check)
This commit is contained in:
commit
844530404d
36 changed files with 875 additions and 97 deletions
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue