prep merge discover
This commit is contained in:
commit
abebd52850
20 changed files with 1023 additions and 102 deletions
|
|
@ -0,0 +1,115 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
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.onRoot
|
||||
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.CompletableDeferred
|
||||
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
|
||||
|
||||
/** Refreshing a loaded Discover title must preserve its real header instead of rebuilding a skeleton. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
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 ->
|
||||
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", false)
|
||||
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))
|
||||
}
|
||||
}
|
||||
} }
|
||||
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 && model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(640)
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-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)
|
||||
coEvery { facade.findHeld(any(), any()) } coAnswers { refreshLookup.await() }
|
||||
rule.runOnIdle {
|
||||
model.load(route, person, preferences, fixtures = false, force = true)
|
||||
assertEquals("An already-loaded catalogue title must never emit Loading on refresh", TitleReadStatus.Ready, model.title!!.data.status)
|
||||
}
|
||||
repeat(12) { index ->
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
assertEquals(TitleReadStatus.Ready, model.title!!.data.status)
|
||||
assertFalse("A refresh cannot focus the root navigation", shell.navFocused)
|
||||
assertEquals("Loaded actions cannot replay their rise", bounds, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot)
|
||||
assertEquals("Loaded actions cannot replay their fade", baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels))
|
||||
}
|
||||
} finally {
|
||||
model.viewModelScope.cancel()
|
||||
refreshLookup.complete(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun brightness(file: File, bounds: Rect, pixels: IntArray): Long {
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
return try {
|
||||
val width = bounds.width.toInt()
|
||||
val height = bounds.height.toInt()
|
||||
bitmap.getPixels(pixels, 0, width, bounds.left.toInt(), bounds.top.toInt(), width, height)
|
||||
pixels.sumOf { (android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it)).toLong() }
|
||||
} finally { bitmap.recycle() }
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import androidx.activity.ComponentActivity
|
|||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
|
|
@ -69,9 +70,12 @@ class TitleAsyncFocusTest {
|
|||
rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } }
|
||||
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
|
||||
rule.waitForIdle()
|
||||
assertTrue(shell.navFocused)
|
||||
assertFalse("Loading retains destination focus instead of focusing the root nav", shell.navFocused)
|
||||
rule.onNodeWithTag("title-page").assertIsFocused()
|
||||
press(Key.DirectionDown)
|
||||
assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused)
|
||||
assertFalse("Down cannot transfer focus to nav or an initial skeleton", shell.navFocused)
|
||||
assertEquals("header", viewModel.title!!.row)
|
||||
assertEquals(0, viewModel.title!!.column)
|
||||
|
||||
val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList())
|
||||
rule.runOnIdle {
|
||||
|
|
@ -109,9 +113,12 @@ class TitleAsyncFocusTest {
|
|||
} }
|
||||
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
|
||||
rule.waitForIdle()
|
||||
assertTrue(shell.navFocused)
|
||||
assertFalse(shell.navFocused)
|
||||
rule.onNodeWithTag("title-page").assertIsFocused()
|
||||
press(Key.DirectionDown)
|
||||
assertTrue(shell.navFocused)
|
||||
assertFalse("Down cannot enter a pending person skeleton", shell.navFocused)
|
||||
assertEquals("header", viewModel.title!!.row)
|
||||
assertEquals(0, viewModel.title!!.column)
|
||||
rule.runOnIdle {
|
||||
reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead(
|
||||
org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"),
|
||||
|
|
@ -121,7 +128,7 @@ class TitleAsyncFocusTest {
|
|||
rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable }
|
||||
assertNotNull("A real person reply must populate the performer destination", viewModel.person)
|
||||
rule.waitForIdle()
|
||||
assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused)
|
||||
assertFalse("The loaded person retains destination focus on its first credit", shell.navFocused)
|
||||
assertEquals(0, viewModel.person!!.index)
|
||||
press(Key.DirectionRight)
|
||||
assertEquals(1, viewModel.person!!.index)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import androidx.compose.ui.test.onNodeWithTag
|
|||
import androidx.compose.ui.test.performMouseInput
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||
|
|
@ -285,10 +286,14 @@ class TitleKeysTest {
|
|||
render(transform = { it.copy(status = TitleReadStatus.Loading) })
|
||||
rule.onNodeWithText("Cast").assertDoesNotExist()
|
||||
press(Key.DirectionDown)
|
||||
assertTrue(shell.navFocused)
|
||||
assertFalse(shell.navFocused)
|
||||
rule.onNodeWithTag("title-page").assertIsFocused()
|
||||
assertEquals("header", state.row)
|
||||
assertEquals(0, state.column)
|
||||
assertFalse(shell.pickerVisible)
|
||||
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
|
||||
assertEquals(listOf(TitleAction.Pop), effects)
|
||||
assertFalse(shell.pickerVisible)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
|
||||
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
||||
if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key &&
|
||||
title?.data?.status == TitleReadStatus.Ready && title?.data?.item?.held == true &&
|
||||
title?.data?.status == TitleReadStatus.Ready &&
|
||||
(title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) &&
|
||||
!destination.startsWith("person/") && fixture == null) {
|
||||
loadJob?.cancel()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue