Skip to content

Commit

Permalink
Add test cases for configuration changes
Browse files Browse the repository at this point in the history
  • Loading branch information
xxfast committed May 8, 2023
1 parent 48fe99f commit a538a15
Show file tree
Hide file tree
Showing 7 changed files with 268 additions and 178 deletions.
Original file line number Diff line number Diff line change
@@ -1,24 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!--Load images from Unsplash-->
<uses-permission android:name="android.permission.INTERNET" />

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:supportsRtl="true">

<profileable android:shell="true" tools:targetApi="q" />

<activity
android:name="io.github.xxfast.decompose.TestActivity"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Screen> = 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<Int>? = 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<Int> 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)
}
}
}
}
}
}


Original file line number Diff line number Diff line change
Expand Up @@ -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<String>? = Loading): Parcelable
@Parcelize data class DetailState(val detail: String): Parcelable
@Parcelize data class ListState(val items: List<Int>? = Loading): Parcelable
@Parcelize data class DetailState(val count: Int): Parcelable
Loading

0 comments on commit a538a15

Please sign in to comment.