diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index def00d26..ac600ff2 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -18,13 +18,24 @@ jobs: with: java-version: '11' - name: Checkout repository - uses: actions/checkout@c952173edf28a2bd22e1a4926590c1ac39630461 + uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f - - uses: burrunan/gradle-cache-action@v1 + - uses: burrunan/gradle-cache-action@03c71a8ba93d670980695505f48f49daf43704a6 name: Run unit tests with: arguments: testDebug --stacktrace + - name: Run instrumentation tests + uses: reactivecircus/android-emulator-runner@08b092e904025fada32a01b711af1e7ff7b7a4a3 + with: + api-level: 23 + target: default + script: | + adb shell settings put global animator_duration_scale 0 + adb shell settings put global transition_animation_scale 0 + adb shell settings put global window_animation_scale 0 + ./gradlew :app:connectedDebugAndroidTest + - name: (Fail-only) upload test report if: failure() uses: actions/upload-artifact@27bce4eee761b5bc643f46a8dfb41b430c8d05f6 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5423777d..c61d32fd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(Dependencies.ThirdParty.accompanist) implementation(Dependencies.ThirdParty.Moshi.lib) testImplementation(Dependencies.Testing.junit) + androidTestImplementation(Dependencies.AndroidX.Compose.activity) androidTestImplementation(Dependencies.Testing.daggerHilt) - androidTestImplementation(Dependencies.Testing.uiTest) + androidTestImplementation(Dependencies.Testing.AndroidX.Compose.uiTestJunit4) } diff --git a/app/src/androidTest/java/dev/msfjarvis/lobsters/BottomNavigationLayoutTest.kt b/app/src/androidTest/java/dev/msfjarvis/lobsters/BottomNavigationLayoutTest.kt new file mode 100644 index 00000000..d5aa53e2 --- /dev/null +++ b/app/src/androidTest/java/dev/msfjarvis/lobsters/BottomNavigationLayoutTest.kt @@ -0,0 +1,115 @@ +package dev.msfjarvis.lobsters + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertContentDescriptionEquals +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onChildAt +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import dev.msfjarvis.lobsters.ui.main.LobstersBottomNav +import dev.msfjarvis.lobsters.ui.navigation.Destination +import dev.msfjarvis.lobsters.ui.theme.LobstersTheme +import org.junit.Before +import org.junit.Rule +import org.junit.Test + + +class BottomNavigationLayoutTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun setUp() { + composeTestRule.setContent { + LobstersTheme { + var mutableDestination by remember { mutableStateOf(Destination.startDestination) } + + LobstersBottomNav( + currentDestination = mutableDestination, + navigateToDestination = { mutableDestination = it }, + jumpToIndex = {} + ) + } + } + } + + @Test + fun bottomNavItemCountTest() { + // Test to make sure total items are equal to enum objects present in Destination + composeTestRule.onNodeWithTag("LobstersBottomNav") + .assertExists() + .assertIsDisplayed() + .onChildren() + .assertCountEquals(Destination.values().size) + } + + @Test + fun bottomNavItemTest() { + // Check hottest BottomNavItem is rendered correctly + composeTestRule.onNodeWithTag("LobstersBottomNav") + .assertExists() + .assertIsDisplayed() + .onChildAt(0) + .assertTextEquals("Hottest") + .assertContentDescriptionEquals("Hottest") + .assertHasClickAction() + + // Check saved BottomNavItem is rendered correctly + composeTestRule.onNodeWithTag("LobstersBottomNav") + .assertExists() + .assertIsDisplayed() + .onChildAt(1) + .assertTextEquals("Saved") + .assertContentDescriptionEquals("Saved") + .assertHasClickAction() + } + + @Test + fun bottomNavItemSelectedTest() { + // Check hottest BottomNav item is selected + composeTestRule.onNodeWithTag("LobstersBottomNav") + .assertExists() + .assertIsDisplayed() + .onChildAt(0) + .assertIsSelected() + .assertTextEquals("Hottest") + + // Check saved BottomNav item is not selected + composeTestRule.onNodeWithTag("LobstersBottomNav") + .assertExists() + .assertIsDisplayed() + .onChildAt(1) + .assertIsNotSelected() + + // Select the saved BottomNav item + composeTestRule.onNodeWithTag("LobstersBottomNav") + .onChildAt(1) + .performClick() + + // Check hottest BottomNav item is not selected + composeTestRule.onNodeWithTag("LobstersBottomNav") + .assertExists() + .assertIsDisplayed() + .onChildAt(0) + .assertIsNotSelected() + + // Check saved BottomNav item is selected + composeTestRule.onNodeWithTag("LobstersBottomNav") + .assertExists() + .assertIsDisplayed() + .onChildAt(1) + .assertIsSelected() + .assertTextEquals("Saved") + } +} diff --git a/app/src/main/java/dev/msfjarvis/lobsters/ui/main/MainActivity.kt b/app/src/main/java/dev/msfjarvis/lobsters/ui/main/MainActivity.kt index 9119ff01..e85fccc2 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/ui/main/MainActivity.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/ui/main/MainActivity.kt @@ -4,7 +4,6 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.BottomNavigation import androidx.compose.material.BottomNavigationItem @@ -16,9 +15,9 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavHostController import androidx.navigation.compose.KEY_ROUTE import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -31,13 +30,12 @@ import dev.msfjarvis.lobsters.ui.navigation.Destination import dev.msfjarvis.lobsters.ui.posts.HottestPosts import dev.msfjarvis.lobsters.ui.posts.SavedPosts import dev.msfjarvis.lobsters.ui.theme.LobstersTheme -import dev.msfjarvis.lobsters.ui.urllauncher.UrlLauncher import dev.msfjarvis.lobsters.ui.urllauncher.LocalUrlLauncher +import dev.msfjarvis.lobsters.ui.urllauncher.UrlLauncher import dev.msfjarvis.lobsters.ui.viewmodel.LobstersViewModel import dev.msfjarvis.lobsters.util.IconResource -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -64,12 +62,28 @@ fun LobstersApp() { val savedPosts by viewModel.savedPosts.collectAsState() val hottestPostsListState = rememberLazyListState() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = + navBackStackEntry?.arguments?.getString(KEY_ROUTE) ?: Destination.startDestination.route + val currentDestination = Destination.getDestinationFromRoute(currentRoute) + val navigateToDestination: (destination: Destination) -> Unit = { destination -> + navController.navigate(destination.route) { + launchSingleTop = true + popUpTo(navController.graph.startDestination) { inclusive = false } + } + } + val jumpToIndex: (Int) -> Unit = { + coroutineScope.launch { + hottestPostsListState.snapToItemIndex(it) + } + } + Scaffold( bottomBar = { LobstersBottomNav( - navController, - hottestPostsListState, - coroutineScope, + currentDestination, + navigateToDestination, + jumpToIndex, ) }, ) { innerPadding -> @@ -96,14 +110,11 @@ fun LobstersApp() { @Composable fun LobstersBottomNav( - navController: NavHostController, - hottestPostsListState: LazyListState, - coroutineScope: CoroutineScope, + currentDestination: Destination, + navigateToDestination: (destination: Destination) -> Unit, + jumpToIndex: (index: Int) -> Unit, ) { - BottomNavigation { - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = - navBackStackEntry?.arguments?.getString(KEY_ROUTE) ?: Destination.startDestination.route + BottomNavigation(modifier = Modifier.testTag("LobstersBottomNav")) { Destination.values().forEach { screen -> BottomNavigationItem( icon = { @@ -113,18 +124,13 @@ fun LobstersBottomNav( ) }, label = { Text(stringResource(id = screen.labelRes)) }, - selected = currentRoute == screen.route, + selected = currentDestination == screen, alwaysShowLabels = false, onClick = { - if (screen.route != currentRoute) { - navController.navigate(screen.route) { - launchSingleTop = true - popUpTo(navController.graph.startDestination) { inclusive = false } - } + if (screen != currentDestination) { + navigateToDestination(screen) } else if (screen.route == Destination.Hottest.route) { - coroutineScope.launch { - hottestPostsListState.snapToItemIndex(0) - } + jumpToIndex(0) } } ) diff --git a/app/src/main/java/dev/msfjarvis/lobsters/ui/navigation/Destination.kt b/app/src/main/java/dev/msfjarvis/lobsters/ui/navigation/Destination.kt index 083c0763..44d5708a 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/ui/navigation/Destination.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/ui/navigation/Destination.kt @@ -18,5 +18,9 @@ enum class Destination( companion object { val startDestination = Hottest + + fun getDestinationFromRoute(route: String): Destination { + return values().firstOrNull { it.route == route } ?: error("Incorrect route passed") + } } } diff --git a/app/src/main/java/dev/msfjarvis/lobsters/ui/viewmodel/LobstersViewModel.kt b/app/src/main/java/dev/msfjarvis/lobsters/ui/viewmodel/LobstersViewModel.kt index e63564fc..043006ce 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/ui/viewmodel/LobstersViewModel.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/ui/viewmodel/LobstersViewModel.kt @@ -9,12 +9,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dev.msfjarvis.lobsters.data.local.LobstersPost import dev.msfjarvis.lobsters.data.remote.LobstersPagingSource import dev.msfjarvis.lobsters.data.repo.LobstersRepository +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class LobstersViewModel @Inject constructor( diff --git a/buildSrc/src/main/java/BaseProjectConfig.kt b/buildSrc/src/main/java/BaseProjectConfig.kt index 09c8729d..f298d778 100644 --- a/buildSrc/src/main/java/BaseProjectConfig.kt +++ b/buildSrc/src/main/java/BaseProjectConfig.kt @@ -39,12 +39,12 @@ internal fun Project.configureForAllProjects() { google() mavenCentral() jcenter() { - content { - // Indirect dependencies - // https://youtrack.jetbrains.com/issue/IDEA-261387 - includeModule("org.jetbrains.trove4j", "trove4j") - includeGroup("org.jetbrains.kotlinx") - } + content { + // Indirect dependencies + // https://youtrack.jetbrains.com/issue/IDEA-261387 + includeModule("org.jetbrains.trove4j", "trove4j") + includeGroup("org.jetbrains.kotlinx") + } } } tasks.withType { @@ -107,6 +107,8 @@ internal fun TestedExtension.configureCommonAndroidOptions() { exclude("**/*.txt") exclude("**/*.kotlin_module") exclude("**/plugin.properties") + exclude("META-INF/AL2.0") + exclude("META-INF/LGPL2.1") } compileOptions { diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index d1cded46..b25d0c2b 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -8,7 +8,8 @@ private const val DAGGER_HILT_VERSION = "2.32-alpha" object Plugins { const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha05" const val androidGradlePlugin_lintModel = "com.android.tools.lint:lint-model:30.0.0-alpha05" - const val daggerGradlePlugin = "com.google.dagger:hilt-android-gradle-plugin:${DAGGER_HILT_VERSION}" + const val daggerGradlePlugin = + "com.google.dagger:hilt-android-gradle-plugin:${DAGGER_HILT_VERSION}" const val kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.30" const val jsemver = "com.github.zafarkhaja:java-semver:0.9.0" const val sqldelightGradlePlugin = "com.squareup.sqldelight:gradle-plugin:1.4.4" @@ -37,7 +38,8 @@ object Dependencies { const val activity = "androidx.activity:activity-compose:1.3.0-alpha02" const val compiler = "androidx.compose.compiler:compiler:$COMPOSE_VERSION" - const val constraintLayout = "androidx.constraintlayout:constraintlayout-compose:1.0.0-alpha02" + const val constraintLayout = + "androidx.constraintlayout:constraintlayout-compose:1.0.0-alpha02" const val foundation = "androidx.compose.foundation:foundation:$COMPOSE_VERSION" const val foundationLayout = "androidx.compose.foundation:foundation-layout:$COMPOSE_VERSION" const val lifecycleViewModel = "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha01" @@ -94,13 +96,16 @@ object Dependencies { const val daggerHilt = "com.google.dagger:hilt-android-testing:$DAGGER_HILT_VERSION" const val junit = "junit:junit:4.13.1" const val mockWebServer = "com.squareup.okhttp3:mockwebserver3-junit4:5.0.0-alpha.2" - const val uiTest = "androidx.compose.ui:ui-test:$COMPOSE_VERSION" object AndroidX { private const val version = "1.3.1-alpha02" const val runner = "androidx.test:runner:$version" - const val rules = "androidx.test:rules:$version" + + object Compose { + + const val uiTestJunit4 = "androidx.compose.ui:ui-test-junit4:$COMPOSE_VERSION" + } } } } diff --git a/buildSrc/src/main/java/SigningConfig.kt b/buildSrc/src/main/java/SigningConfig.kt index 50b86c47..74979f72 100644 --- a/buildSrc/src/main/java/SigningConfig.kt +++ b/buildSrc/src/main/java/SigningConfig.kt @@ -4,8 +4,8 @@ */ import com.android.build.gradle.internal.dsl.BaseAppModuleExtension -import org.gradle.api.Project import java.util.* +import org.gradle.api.Project private const val KEYSTORE_CONFIG_PATH = "keystore.properties" diff --git a/buildSrc/src/main/java/VersioningPlugin.kt b/buildSrc/src/main/java/VersioningPlugin.kt index 8601f7f1..cbf0ea69 100644 --- a/buildSrc/src/main/java/VersioningPlugin.kt +++ b/buildSrc/src/main/java/VersioningPlugin.kt @@ -6,10 +6,10 @@ import com.android.build.gradle.internal.plugins.AppPlugin import com.github.zafarkhaja.semver.Version -import org.gradle.api.Plugin -import org.gradle.api.Project import java.io.OutputStream import java.util.* +import org.gradle.api.Plugin +import org.gradle.api.Project private const val VERSIONING_PROP_FILE = "version.properties" private const val VERSIONING_PROP_VERSION_NAME = "versioning-plugin.versionName"