From a538a15b2d88df77a5e9e0f135c59734667ca396 Mon Sep 17 00:00:00 2001 From: xxfast Date: Mon, 8 May 2023 15:28:22 +1000 Subject: [PATCH] Add test cases for configuration changes --- .../AndroidManifest.xml | 13 +- .../github/xxfast/decompose/TestActivity.kt | 9 +- ...WithActivity.kt => TestDecomposeRouter.kt} | 71 +++++-- .../xxfast/decompose/screen/TestInstances.kt | 3 +- .../xxfast/decompose/screen/TestScreens.kt | 201 ++++++++++++++++++ .../decompose/screen/TestStateModels.kt | 8 +- .../xxfast/decompose/screen/TestScreens.kt | 141 ------------ 7 files changed, 268 insertions(+), 178 deletions(-) rename decompose-router/src/{androidMain => androidInstrumentedTest}/AndroidManifest.xml (69%) rename decompose-router/src/{androidMain => androidInstrumentedTest}/kotlin/io/github/xxfast/decompose/TestActivity.kt (78%) rename decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/{TestDecomposeRouterWithActivity.kt => TestDecomposeRouter.kt} (57%) rename decompose-router/src/{androidMain => androidInstrumentedTest}/kotlin/io/github/xxfast/decompose/screen/TestInstances.kt (94%) create mode 100644 decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/screen/TestScreens.kt rename decompose-router/src/{androidMain => androidInstrumentedTest}/kotlin/io/github/xxfast/decompose/screen/TestStateModels.kt (50%) delete mode 100644 decompose-router/src/androidMain/kotlin/io/github/xxfast/decompose/screen/TestScreens.kt diff --git a/decompose-router/src/androidMain/AndroidManifest.xml b/decompose-router/src/androidInstrumentedTest/AndroidManifest.xml similarity index 69% rename from decompose-router/src/androidMain/AndroidManifest.xml rename to decompose-router/src/androidInstrumentedTest/AndroidManifest.xml index b132adc..9325fcb 100644 --- a/decompose-router/src/androidMain/AndroidManifest.xml +++ b/decompose-router/src/androidInstrumentedTest/AndroidManifest.xml @@ -1,24 +1,15 @@ - - - - - + - - - - - diff --git a/decompose-router/src/androidMain/kotlin/io/github/xxfast/decompose/TestActivity.kt b/decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/TestActivity.kt similarity index 78% rename from decompose-router/src/androidMain/kotlin/io/github/xxfast/decompose/TestActivity.kt rename to decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/TestActivity.kt index 3c6bf73..ae08da3 100644 --- a/decompose-router/src/androidMain/kotlin/io/github/xxfast/decompose/TestActivity.kt +++ b/decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/TestActivity.kt @@ -4,6 +4,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.CompositionLocalProvider import androidx.core.view.WindowCompat import com.arkivanov.decompose.DefaultComponentContext @@ -17,9 +18,11 @@ class TestActivity : ComponentActivity() { val rootComponentContext: DefaultComponentContext = defaultComponentContext() setContent { - CompositionLocalProvider(LocalComponentContext provides rootComponentContext) { - MaterialTheme { - HomeScreen() + Surface { + CompositionLocalProvider(LocalComponentContext provides rootComponentContext) { + MaterialTheme { + HomeScreen() + } } } } diff --git a/decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/TestDecomposeRouterWithActivity.kt b/decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/TestDecomposeRouter.kt similarity index 57% rename from decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/TestDecomposeRouterWithActivity.kt rename to decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/TestDecomposeRouter.kt index 2eaa76a..89148d8 100644 --- a/decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/TestDecomposeRouterWithActivity.kt +++ b/decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/TestDecomposeRouter.kt @@ -1,27 +1,22 @@ package io.github.xxfast.decompose -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.CompositionLocalProvider +import android.content.pm.ActivityInfo import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.hasProgressBarRangeInfo import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onChildAt +import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode -import androidx.core.view.WindowCompat import androidx.test.ext.junit.rules.ActivityScenarioRule -import com.arkivanov.decompose.DefaultComponentContext -import com.arkivanov.decompose.defaultComponentContext import io.github.xxfast.decompose.screen.BACK_BUTTON_TAG import io.github.xxfast.decompose.screen.DETAILS_TAG -import io.github.xxfast.decompose.screen.HomeScreen import io.github.xxfast.decompose.screen.LAZY_COLUMN_TAG import io.github.xxfast.decompose.screen.TITLEBAR_TAG import org.junit.Rule @@ -44,35 +39,75 @@ class TestDecomposeRouterWithActivity { fun testBasicNavigation(): Unit = with(composeRule) { // Check the initial state onNode(circularProgressIndicator).assertExists() - onNode(titleBar).assertExists().assertTextEquals("Home") + onNode(titleBar).assertExists().assertTextEquals("Welcome") // Wait till the screen is populated waitUntilAtLeastOneExists(lazyColumn) - // Go to the 50th item - var testItem = "50" + // Go to the 4th item + var testItem = "4" onNode(lazyColumn).performScrollToNode(hasText(testItem)) // Click on the 10th item onNode(hasText(testItem)).performClick() // Verify if detail is shown is correct onNode(titleBar).assertExists().assertTextEquals(testItem) - onNode(details).assertExists().assertTextEquals("Details: $testItem") - // Navigate back - onNode(backButton).assertExists().performClick() - onNode(titleBar).assertExists().assertTextEquals("Home") + // Do the little game + onNode(details).onChildren().fetchSemanticsNodes().forEachIndexed { index, _ -> + onNode(details).onChildAt(index).performClick() + } + + // Verify if auto-navigation works + onNode(titleBar).assertExists().assertTextEquals("Welcome") // Verify if state and scroll position is restored onNode(circularProgressIndicator).assertDoesNotExist() onNode(hasText(testItem)).assertExists() - // Go to the 100th item and navigate back + // Go to the 100th item testItem = "100" onNode(lazyColumn).performScrollToNode(hasText(testItem)) onNode(hasText(testItem)).performClick() onNode(titleBar).assertExists().assertTextEquals(testItem) - onNode(details).assertExists().assertTextEquals("Details: $testItem") + onNode(circularProgressIndicator).assertDoesNotExist() + onNode(hasText(testItem)).assertExists() + + // Test go back + onNode(backButton).assertExists().performClick() + onNode(titleBar).assertExists().assertTextEquals("Welcome") + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun testRetainInstanceAcrossConfigurationChanges(): Unit = with(composeRule) { + // Wait till the screen is populated + waitUntilAtLeastOneExists(lazyColumn) + + // Trigger configuration change + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + + // Test if the loaded data is not lost + onNode(lazyColumn).assertExists() + onNode(circularProgressIndicator).assertDoesNotExist() + + // Go to the 50th item + val testItem = "50" + onNode(lazyColumn).performScrollToNode(hasText(testItem)) + // Click on the 10th item + onNode(hasText(testItem)).performClick() + + // Verify if detail is shown is correct + onNode(titleBar).assertExists().assertTextEquals(testItem) + + // Trigger configuration change again + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + + // Navigate back + onNode(backButton).assertExists().performClick() + onNode(titleBar).assertExists().assertTextEquals("Welcome") + + // Verify if state and scroll position is restored onNode(circularProgressIndicator).assertDoesNotExist() onNode(hasText(testItem)).assertExists() } diff --git a/decompose-router/src/androidMain/kotlin/io/github/xxfast/decompose/screen/TestInstances.kt b/decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/screen/TestInstances.kt similarity index 94% rename from decompose-router/src/androidMain/kotlin/io/github/xxfast/decompose/screen/TestInstances.kt rename to decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/screen/TestInstances.kt index a4b5a89..0af235a 100644 --- a/decompose-router/src/androidMain/kotlin/io/github/xxfast/decompose/screen/TestInstances.kt +++ b/decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/screen/TestInstances.kt @@ -20,12 +20,13 @@ class ListInstance(savedStateHandle: SavedStateHandle) : Instance, CoroutineScop flow { emit(ListState(Loading)) delay(300L) - emit(ListState((1.. 100).map { it.toString() })) + emit(ListState((1.. 100).toList())) } .stateIn(this, Lazily, initialState) } override val coroutineContext: CoroutineContext = Dispatchers.Main + override fun onDestroy() { coroutineContext.cancel() } diff --git a/decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/screen/TestScreens.kt b/decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/screen/TestScreens.kt new file mode 100644 index 0000000..6187aaa --- /dev/null +++ b/decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/screen/TestScreens.kt @@ -0,0 +1,201 @@ +package io.github.xxfast.decompose.screen + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.Center +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.slide +import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.stackAnimation +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.router.stack.push +import io.github.xxfast.decompose.router.Router +import io.github.xxfast.decompose.router.content.RoutedContent +import io.github.xxfast.decompose.router.rememberRouter +import io.github.xxfast.decompose.router.rememberViewModel +import io.github.xxfast.decompose.screen.Screen.Round + +const val TOOLBAR_TAG = "toolbar" +const val BACK_BUTTON_TAG = "back" +const val TITLEBAR_TAG = "titleBar" +const val DETAILS_TAG = "details" +const val LAZY_COLUMN_TAG = "lazyColumn" + +@Composable +fun HomeScreen() { + val router: Router = rememberRouter(Screen::class, listOf(Screen.Game)) + RoutedContent( + router = router, + animation = stackAnimation(slide()) + ) { screen -> + when (screen) { + Screen.Game -> ListScreen( + onSelect = { count -> router.push(Round(count)) }, + ) + + is Round -> DetailScreen( + count = screen.number, + onBack = { router.pop() } + ) + } + } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListScreen( + onSelect: (count: Int) -> Unit, +) { + val instance: ListInstance = rememberViewModel(ListInstance::class) { savedState -> + ListInstance(savedState) + } + + val state: ListState by instance.state.collectAsState() + val items: List? = state.items + + Scaffold( + topBar = { + LargeTopAppBar( + modifier = Modifier.testTag(TOOLBAR_TAG), + title = { + Text( + text = "Welcome", + modifier = Modifier.testTag(TITLEBAR_TAG) + ) + } + ) + }) { paddingValues -> + + if (items == Loading) Box( + modifier = Modifier + .padding(paddingValues) + .fillMaxWidth() + ) { + CircularProgressIndicator(modifier = Modifier.align(Center)) + } else LazyVerticalGrid( + columns = GridCells.Adaptive(100.dp), + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .testTag(LAZY_COLUMN_TAG), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + ) { + items(items) { item -> + Card( + modifier = Modifier + .height(100.dp) + .clip(MaterialTheme.shapes.medium) + .clickable { onSelect(item) }, + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Center) { + Text(text = item.toString(), style = MaterialTheme.typography.titleLarge) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DetailScreen( + count: Int, + onBack: () -> Unit, +) { + var counted: Set by rememberSaveable { mutableStateOf(emptySet()) } + + if (counted.size == count) onBack() + + Scaffold( + topBar = { + LargeTopAppBar( + modifier = Modifier.testTag(TOOLBAR_TAG), + title = { + Text( + text = count.toString(), + modifier = Modifier.testTag(TITLEBAR_TAG) + ) + }, + navigationIcon = { + IconButton( + modifier = Modifier + .testTag(BACK_BUTTON_TAG), + onClick = onBack + ) { + Icon(Icons.Default.ArrowBack, null) + } + }) + }) { paddingValues -> + LazyHorizontalGrid( + rows = GridCells.Adaptive(100.dp), + modifier = Modifier + .padding(paddingValues) + .testTag(DETAILS_TAG), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + ) { + items(count) { i -> + val cornerSize: Dp by animateDpAsState(if (i in counted) 64.dp else 8.dp) + + Card( + modifier = Modifier + .width(100.dp) + .clickable { counted = if (i in counted) counted - i else counted + i } + .clip(RoundedCornerShape(cornerSize)), + colors = CardDefaults.cardColors( + containerColor = + if (i in counted) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.primaryContainer + ), + shape = RoundedCornerShape(cornerSize) + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Center) { + if (i in counted) Icon(imageVector = Icons.Default.Check, null) + else Text(text = i.toString(), style = MaterialTheme.typography.titleLarge) + } + } + } + } + } +} + + diff --git a/decompose-router/src/androidMain/kotlin/io/github/xxfast/decompose/screen/TestStateModels.kt b/decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/screen/TestStateModels.kt similarity index 50% rename from decompose-router/src/androidMain/kotlin/io/github/xxfast/decompose/screen/TestStateModels.kt rename to decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/screen/TestStateModels.kt index 9663bd5..079b5c3 100644 --- a/decompose-router/src/androidMain/kotlin/io/github/xxfast/decompose/screen/TestStateModels.kt +++ b/decompose-router/src/androidInstrumentedTest/kotlin/io/github/xxfast/decompose/screen/TestStateModels.kt @@ -5,10 +5,10 @@ import com.arkivanov.essenty.parcelable.Parcelize @Parcelize sealed class Screen: Parcelable { - object List: Screen() - data class Detail(val detail: String): Screen() + object Game: Screen() + data class Round(val number: Int): Screen() } val Loading: Nothing? = null -@Parcelize data class ListState(val items: List? = Loading): Parcelable -@Parcelize data class DetailState(val detail: String): Parcelable +@Parcelize data class ListState(val items: List? = Loading): Parcelable +@Parcelize data class DetailState(val count: Int): Parcelable diff --git a/decompose-router/src/androidMain/kotlin/io/github/xxfast/decompose/screen/TestScreens.kt b/decompose-router/src/androidMain/kotlin/io/github/xxfast/decompose/screen/TestScreens.kt deleted file mode 100644 index 890bdf3..0000000 --- a/decompose-router/src/androidMain/kotlin/io/github/xxfast/decompose/screen/TestScreens.kt +++ /dev/null @@ -1,141 +0,0 @@ -package io.github.xxfast.decompose.screen - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import com.arkivanov.decompose.router.stack.pop -import com.arkivanov.decompose.router.stack.push -import io.github.xxfast.decompose.router.Router -import io.github.xxfast.decompose.router.content.RoutedContent -import io.github.xxfast.decompose.router.rememberRouter -import io.github.xxfast.decompose.router.rememberViewModel -import io.github.xxfast.decompose.screen.Screen.Detail - -const val TOOLBAR_TAG = "toolbar" -const val BACK_BUTTON_TAG = "back" -const val TITLEBAR_TAG = "titleBar" -const val DETAILS_TAG = "details" -const val LAZY_COLUMN_TAG = "lazyColumn" - -@Composable -fun HomeScreen() { - val router: Router = rememberRouter(Screen::class, listOf(Screen.List)) - RoutedContent(router = router) { screen -> - when (screen) { - Screen.List -> ListScreen( - onSelect = { detail -> router.push(Detail(detail)) }, - ) - - is Detail -> DetailsScreen( - detail = screen.detail, - onBack = { router.pop() } - ) - } - } -} - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ListScreen( - onSelect: (detail: String) -> Unit, -) { - val instance: ListInstance = - rememberViewModel(ListInstance::class) { savedState -> ListInstance(savedState) } - val state: ListState by instance.state.collectAsState() - val items: List? = state.items - - Scaffold( - topBar = { - TopAppBar( - modifier = Modifier.testTag(TOOLBAR_TAG), - title = { - Text( - text = "Home", - modifier = Modifier.testTag(TITLEBAR_TAG) - ) - } - ) - }) { paddingValues -> - - if (items == Loading) Box( - modifier = Modifier - .padding(paddingValues) - .fillMaxWidth() - ) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } else LazyColumn( - modifier = Modifier.fillMaxSize().testTag(LAZY_COLUMN_TAG) - ) { - items(items) { item -> - ListItem( - modifier = Modifier - .fillMaxWidth() - .clickable { onSelect(item) }, - headlineText = { Text(text = item) } - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DetailsScreen( - detail: String, - onBack: () -> Unit, -) { - Scaffold( - topBar = { - TopAppBar( - modifier = Modifier.testTag(TOOLBAR_TAG), - title = { - Text( - text = detail, - modifier = Modifier.testTag(TITLEBAR_TAG) - ) - }, - navigationIcon = { - IconButton( - modifier = Modifier - .testTag(BACK_BUTTON_TAG), - onClick = onBack - ) { - Icon(Icons.Default.ArrowBack, null) - } - }) - }) { paddingValues -> - Box( - modifier = Modifier - .padding(paddingValues) - .fillMaxWidth() - ) { - Text( - text = "Details: $detail", - modifier = Modifier.testTag(DETAILS_TAG) - ) - } - } -} - -