diff --git a/.github/workflows/manual_deploy_to_firebase_workflow.yml b/.github/workflows/manual_deploy_to_firebase_workflow.yml new file mode 100644 index 0000000..316fb7f --- /dev/null +++ b/.github/workflows/manual_deploy_to_firebase_workflow.yml @@ -0,0 +1,68 @@ +name: Deploy Android Debug +on: + workflow_dispatch: + inputs: + versionCode: + description: Version Code (eg. 123) + required: true + versionName: + description: Version Name (eg 1.2.3) + required: true + +env: + VERSION_CODE: ${{ github.event.inputs.versionCode }} + VERSION_NAME: ${{ github.event.inputs.versionName }} + +jobs: + + build: + name: 🔨 Build + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v3 + with: + distribution: adopt + java-version: "17" + + - name: Make gradle executable + run: chmod +x ./gradlew + + - name: Build with Gradle + run: ./gradlew build --stacktrace + + deploy: + name: 🚀 Deploy to Firebase App Distribution + needs: + - build + runs-on: ubuntu-latest + steps: + + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v3 + with: + distribution: adopt + java-version: "17" + + - name: Make gradle executable + run: chmod +x ./gradlew + + - name: Get local.properties from secrets + run: echo "${{secrets.ProjectSecrets }}" > $GITHUB_WORKSPACE/local.properties + + - name: Build Debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Firebase App Distribution + uses: wzieba/Firebase-Distribution-Github-Action@v1.7.0 + with: + appId: ${{secrets.FIREBASE_APP_ID}} + serviceCredentialsFileContent: ${{secrets.CREDENTIAL_FILE_CONTENT}} + groups: testers + file: app/build/outputs/apk/debug/app-debug.apk diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml new file mode 100644 index 0000000..74780fc --- /dev/null +++ b/.idea/androidTestResultsUserPreferences.xml @@ -0,0 +1,113 @@ + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 44794e9..b589d56 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,10 +1,6 @@ - - - - - + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..e80ef22 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/git_toolbox_prj.xml b/.idea/git_toolbox_prj.xml new file mode 100644 index 0000000..02b915b --- /dev/null +++ b/.idea/git_toolbox_prj.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index d3cddf7..141199a 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -1,6 +1,5 @@ - - \ No newline at end of file + diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..44ca2d9 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,41 @@ + + + + \ No newline at end of file diff --git a/.idea/ktlint-plugin.xml b/.idea/ktlint-plugin.xml new file mode 100644 index 0000000..bee5678 --- /dev/null +++ b/.idea/ktlint-plugin.xml @@ -0,0 +1,6 @@ + + + + DISTRACT_FREE + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..b1b87d7 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 61576aa..d45b404 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,6 +50,9 @@ dependencies { implementation(projects.core.domain) implementation(libs.firebase.crashlytics) implementation(libs.firebase.analytics) + // For Robolectric tests. + testImplementation(libs.hilt.android.testing) + kspTest(libs.hilt.compiler) } sentry { diff --git a/app/src/main/java/com/devmike/gitissuesmobile/navigation/AppNavigation.kt b/app/src/main/java/com/devmike/gitissuesmobile/navigation/AppNavigation.kt index 85c3e15..7020600 100644 --- a/app/src/main/java/com/devmike/gitissuesmobile/navigation/AppNavigation.kt +++ b/app/src/main/java/com/devmike/gitissuesmobile/navigation/AppNavigation.kt @@ -17,7 +17,9 @@ fun AppNavigation( startDestination = com.devmike.domain.appdestinations.AppDestinations.RepositorySearch, ) { composable { - RepositorySearchScreen(onLogoutClicked = onLogout) { name, owner -> + RepositorySearchScreen( + onLogoutClicked = onLogout, + ) { name, owner -> val destination = com.devmike.domain.appdestinations.AppDestinations .Issues(repository = name, owner = owner) diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 1b1969c..cf9e0de 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -13,8 +13,8 @@ android { dependencies { api(projects.core.domain) - implementation(projects.core.network) - implementation(projects.core.database) + api(projects.core.network) + api(projects.core.database) testImplementation(libs.androidx.test.ext) testImplementation(libs.mockk) testImplementation(libs.turbine) diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index f5091eb..39828ae 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -9,7 +9,8 @@ android { } dependencies { - + implementation(projects.core.data) + implementation(projects.core.datastore) implementation(libs.androidx.arch.core.testing) implementation(libs.kotlinx.coroutines.test) implementation(libs.core.ktx) diff --git a/feature/repository/build.gradle.kts b/feature/repository/build.gradle.kts index 6582039..81cdcd7 100644 --- a/feature/repository/build.gradle.kts +++ b/feature/repository/build.gradle.kts @@ -20,13 +20,16 @@ dependencies { implementation(projects.core.data) implementation(projects.core.domain) + debugImplementation(libs.ui.test.manifest) implementation(libs.androidx.paging.compose) implementation(libs.coil.compose) testImplementation(libs.mockk) testImplementation(libs.turbine) testImplementation(libs.androidx.arch.core.testing) + androidTestImplementation(libs.androidx.arch.core.testing) testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.core.ktx) testImplementation(libs.truth) // alternatively - without Android dependencies for tests diff --git a/feature/repository/src/androidTest/AndroidManifest.xml b/feature/repository/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..2caf7c3 --- /dev/null +++ b/feature/repository/src/androidTest/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/repository/src/main/java/com/devmike/repository/screen/RepositorySearchScreen.kt b/feature/repository/src/main/java/com/devmike/repository/screen/RepositorySearchScreen.kt index 98ec31a..83ce08f 100644 --- a/feature/repository/src/main/java/com/devmike/repository/screen/RepositorySearchScreen.kt +++ b/feature/repository/src/main/java/com/devmike/repository/screen/RepositorySearchScreen.kt @@ -12,12 +12,14 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.compose.collectAsLazyPagingItems @@ -33,6 +35,9 @@ fun RepositorySearchScreen( onLogoutClicked: () -> Unit, onRepositoryClick: (name: String, owner: String) -> Unit, ) { + LaunchedEffect(viewModel) { + viewModel.modifyDebounceTime(DEBOUNCE_DURATION) + } val repositoriesState = viewModel.searchResults.collectAsLazyPagingItems() val showIdleScreen = @@ -64,7 +69,10 @@ fun RepositorySearchScreen( ) }, actions = { - IconButton(onClick = { showLogoutDialog = true }) { + IconButton( + onClick = { showLogoutDialog = true }, + modifier = Modifier.testTag("logout_button"), + ) { Icon( imageVector = Icons.AutoMirrored.Filled.Logout, contentDescription = "logout", @@ -81,7 +89,7 @@ fun RepositorySearchScreen( IdleScreen(modifier = Modifier.padding(paddingValues)) } else { RepositoryListScreen( - modifier = Modifier.padding(paddingValues), + modifier = Modifier.padding(paddingValues).testTag("repositorieslist"), repositoriesState = repositoriesState, onRepositoryClick = onRepositoryClick, ) @@ -91,3 +99,4 @@ fun RepositorySearchScreen( } const val MINIMUM_SEARCH_LENGTH = 3 +const val DEBOUNCE_DURATION = 500L diff --git a/feature/repository/src/main/java/com/devmike/repository/screen/RepositorySearchViewModel.kt b/feature/repository/src/main/java/com/devmike/repository/screen/RepositorySearchViewModel.kt index 8a65c76..9a190f7 100644 --- a/feature/repository/src/main/java/com/devmike/repository/screen/RepositorySearchViewModel.kt +++ b/feature/repository/src/main/java/com/devmike/repository/screen/RepositorySearchViewModel.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.paging.PagingData import androidx.paging.cachedIn import com.devmike.domain.repository.IRepoSearchRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -15,7 +14,7 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -24,7 +23,7 @@ class RepositorySearchViewModel constructor( private val repoSearchRepository: IRepoSearchRepository, ) : ViewModel() { - private val debounceDuration = 500L + private var debounceDuration = 0L var searchQuery by mutableStateOf("") private set @@ -36,19 +35,24 @@ class RepositorySearchViewModel ( if (query.isEmpty()) { - flowOf(PagingData.empty()) + flowOf() } else { - repoSearchRepository.searchRepositories(query.trim()) + + val data = repoSearchRepository.searchRepositories(query.trim()) + + data.cachedIn(viewModelScope) } + ) }.cachedIn(viewModelScope) - .stateIn( - viewModelScope, - kotlinx.coroutines.flow.SharingStarted.Lazily, - PagingData.empty(), - ) fun modifySearchQuery(query: String) { - searchQuery = query + viewModelScope.launch { + searchQuery = query + } + } + + fun modifyDebounceTime(duration: Long) { + debounceDuration = duration } } diff --git a/feature/repository/src/main/java/com/devmike/repository/screen/components/LogoutConfirmationDialog.kt b/feature/repository/src/main/java/com/devmike/repository/screen/components/LogoutConfirmationDialog.kt index 6a1d1e4..90435d8 100644 --- a/feature/repository/src/main/java/com/devmike/repository/screen/components/LogoutConfirmationDialog.kt +++ b/feature/repository/src/main/java/com/devmike/repository/screen/components/LogoutConfirmationDialog.kt @@ -8,7 +8,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview @Composable @@ -32,6 +34,7 @@ fun LogoutConfirmationDialog( Text("Keep me logged in") } }, + modifier = Modifier.testTag("Logout_Dialog"), ) } } diff --git a/feature/repository/src/main/java/com/devmike/repository/screen/components/NoRepositoriesFoundScreen.kt b/feature/repository/src/main/java/com/devmike/repository/screen/components/NoRepositoriesFoundScreen.kt index 51c0379..f55c4dd 100644 --- a/feature/repository/src/main/java/com/devmike/repository/screen/components/NoRepositoriesFoundScreen.kt +++ b/feature/repository/src/main/java/com/devmike/repository/screen/components/NoRepositoriesFoundScreen.kt @@ -32,7 +32,7 @@ fun NoRepositoriesFoundScreen() { ) { Image( painter = painterResource(id = R.drawable.not_found), - contentDescription = "No repositoriesfound", + contentDescription = "No repositories found", modifier = Modifier.size(120.dp), colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), ) diff --git a/feature/repository/src/main/java/com/devmike/repository/screen/components/RepositoryItem.kt b/feature/repository/src/main/java/com/devmike/repository/screen/components/RepositoryItem.kt index 5425880..69e340a 100644 --- a/feature/repository/src/main/java/com/devmike/repository/screen/components/RepositoryItem.kt +++ b/feature/repository/src/main/java/com/devmike/repository/screen/components/RepositoryItem.kt @@ -22,18 +22,17 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.devmike.domain.models.RepositoryModel -import timber.log.Timber @Composable fun RepositoryItem( @@ -42,11 +41,8 @@ fun RepositoryItem( ) { val context = LocalContext.current - LaunchedEffect(key1 = repository) { - Timber.tag("repoScreen").d("$repository") - } Card( - modifier = Modifier.fillMaxWidth().padding(4.dp), + modifier = Modifier.fillMaxWidth().padding(4.dp).testTag("repository_item"), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), onClick = { if (repository.issueCount > diff --git a/feature/repository/src/main/java/com/devmike/repository/screen/components/RepositoryListScreen.kt b/feature/repository/src/main/java/com/devmike/repository/screen/components/RepositoryListScreen.kt index 7b12b70..6c2284c 100644 --- a/feature/repository/src/main/java/com/devmike/repository/screen/components/RepositoryListScreen.kt +++ b/feature/repository/src/main/java/com/devmike/repository/screen/components/RepositoryListScreen.kt @@ -17,12 +17,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.paging.LoadState -import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import com.devmike.domain.models.RepositoryModel -import kotlinx.coroutines.flow.flow +import com.devmike.repository.utils.createTestPagingFlow @Composable fun RepositoryListScreen( @@ -34,14 +33,14 @@ fun RepositoryListScreen( modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { - items( + /* items( repositoriesState.itemCount, key = repositoriesState.itemKey { it.url }, ) { count -> repositoriesState[count]?.let { RepositoryItem(repository = it, onRepositoryClick) } - } + }*/ repositoriesState.apply { when { loadState.refresh is LoadState.NotLoading -> { @@ -129,85 +128,76 @@ fun RepositoryListScreen( @Preview fun RepositoryListScreenPreview() { val items = - flow { -// emit(PagingData.empty(LoadStates( -// refresh = LoadState.Loading, -// prepend = LoadState.NotLoading(false), -// append = LoadState.NotLoading(false) -// ))) -// -// kotlinx.coroutines.delay(2000L) - emit( - PagingData.from( - listOf( - RepositoryModel( - url = "https://github.com/square/retrofit", - name = "Retrofit", - nameWithOwner = "square/retrofit", - owner = "square", - description = "A type-safe HTTP client for Android and Java.", - stargazers = 40000, - forkCount = 8000, issueCount = 1500, - avatarUrl = "https://avatars.githubusercontent.com/u/82592", - ), - RepositoryModel( - url = "https://github.com/bumptech/glide", - name = "Glide", - nameWithOwner = "bumptech/glide", - owner = "bumptech", - description = - """An image loading and caching library + + createTestPagingFlow( + listOf( + RepositoryModel( + url = "https://github.com/square/retrofit", + name = "Retrofit", + nameWithOwner = "square/retrofit", + owner = "square", + description = "A type-safe HTTP client for Android and Java.", + stargazers = 40000, + forkCount = 8000, issueCount = 1500, + avatarUrl = "https://avatars.githubusercontent.com/u/82592", + ), + RepositoryModel( + url = "https://github.com/bumptech/glide", + name = "Glide", + nameWithOwner = "bumptech/glide", + owner = "bumptech", + description = + """An image loading and caching library |for Android focused on smooth scrolling. - """.trimMargin(), - stargazers = 35000, - forkCount = 6500, - issueCount = 1200, - avatarUrl = "https://avatars.githubusercontent.com/u/4255330", - ), - RepositoryModel( - url = "https://github.com/JetBrains/kotlin", - name = "Kotlin", - nameWithOwner = "JetBrains/kotlin", - owner = "JetBrains", - description = "The Kotlin Programming Language.", - stargazers = 41000, - forkCount = 7800, - issueCount = 2300, - avatarUrl = "https://avatars.githubusercontent.com/u/878438", - ), - RepositoryModel( - url = "https://github.com/androidx/androidx", - name = "AndroidX", - nameWithOwner = "androidx/androidx", - owner = "androidx", - description = - """ - AndroidX is the open-source project that the Android team uses to - develop, test, package, version and release libraries within Jetpack. - """.trimIndent(), - stargazers = 10000, - forkCount = 3500, issueCount = 800, - avatarUrl = "https://avatars.githubusercontent.com/u/44576557", - ), - RepositoryModel( - url = "https://github.com/coil-kt/coil", - name = "Coil", - nameWithOwner = "coil-kt/coil", - owner = "coil-kt", - description = - """ - "An image loading library for - Android backed by Kotlin Coroutines." - """.trimIndent(), - stargazers = 8000, - forkCount = 1000, - issueCount = 300, - avatarUrl = "https://avatars.githubusercontent.com/u/70237063", - ), - ), + """.trimMargin(), + stargazers = 35000, + forkCount = 6500, + issueCount = 1200, + avatarUrl = "https://avatars.githubusercontent.com/u/4255330", ), - ) - } + RepositoryModel( + url = "https://github.com/JetBrains/kotlin", + name = "Kotlin", + nameWithOwner = "JetBrains/kotlin", + owner = "JetBrains", + description = "The Kotlin Programming Language.", + stargazers = 41000, + forkCount = 7800, + issueCount = 2300, + avatarUrl = "https://avatars.githubusercontent.com/u/878438", + ), + RepositoryModel( + url = "https://github.com/androidx/androidx", + name = "AndroidX", + nameWithOwner = "androidx/androidx", + owner = "androidx", + description = + """ + AndroidX is the open-source project that the Android team uses to + develop, test, package, version and release libraries within Jetpack. + """.trimIndent(), + stargazers = 10000, + forkCount = 3500, issueCount = 800, + avatarUrl = "https://avatars.githubusercontent.com/u/44576557", + ), + RepositoryModel( + url = "https://github.com/coil-kt/coil", + name = "Coil", + nameWithOwner = "coil-kt/coil", + owner = "coil-kt", + description = + """ + "An image loading library for + Android backed by Kotlin Coroutines." + """.trimIndent(), + stargazers = 8000, + forkCount = 1000, + issueCount = 300, + avatarUrl = "https://avatars.githubusercontent.com/u/70237063", + ), + ), + ) + RepositoryListScreen( modifier = Modifier, repositoriesState = items.collectAsLazyPagingItems(), diff --git a/feature/repository/src/main/java/com/devmike/repository/screen/components/SearchTextField.kt b/feature/repository/src/main/java/com/devmike/repository/screen/components/SearchTextField.kt index d4cc86d..b15106e 100644 --- a/feature/repository/src/main/java/com/devmike/repository/screen/components/SearchTextField.kt +++ b/feature/repository/src/main/java/com/devmike/repository/screen/components/SearchTextField.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -50,7 +51,8 @@ fun SearchTextField( modifier .fillMaxWidth() .focusRequester(requester) - .padding(horizontal = 4.dp), + .padding(horizontal = 4.dp) + .testTag("search_repositories"), leadingIcon = { Icon( imageVector = Icons.Default.Search, diff --git a/feature/repository/src/main/java/com/devmike/repository/utils/TestPagingScource.kt b/feature/repository/src/main/java/com/devmike/repository/utils/TestPagingScource.kt new file mode 100644 index 0000000..3e9e8e2 --- /dev/null +++ b/feature/repository/src/main/java/com/devmike/repository/utils/TestPagingScource.kt @@ -0,0 +1,26 @@ +package com.devmike.repository.utils + +import androidx.paging.LoadState +import androidx.paging.LoadStates +import androidx.paging.PagingData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +fun createTestPagingFlow( + items: List, + refreshState: LoadState = LoadState.NotLoading(endOfPaginationReached = false), + appendState: LoadState = LoadState.NotLoading(endOfPaginationReached = false), + prependState: LoadState = LoadState.NotLoading(endOfPaginationReached = false), +): Flow> { + return flowOf( + PagingData.from( + data = items, + sourceLoadStates = + LoadStates( + refresh = refreshState, + append = appendState, + prepend = prependState, + ), + ), + ) +} diff --git a/feature/repository/src/test/java/com/devmike/repository/SearchViewModelTest.kt b/feature/repository/src/test/java/com/devmike/repository/SearchViewModelTest.kt index 769e336..e1ec057 100644 --- a/feature/repository/src/test/java/com/devmike/repository/SearchViewModelTest.kt +++ b/feature/repository/src/test/java/com/devmike/repository/SearchViewModelTest.kt @@ -65,6 +65,7 @@ class SearchViewModelTest { // When search query is modified viewModel.modifySearchQuery(query) + advanceUntilIdle() // Then searchQuery should be updated Truth.assertThat(viewModel.searchQuery).isEqualTo(query) diff --git a/feature/repository/src/test/java/com/devmike/repository/testdouble/RepositorySearchDouble.kt b/feature/repository/src/test/java/com/devmike/repository/testdouble/RepositorySearchDouble.kt new file mode 100644 index 0000000..ac7d04b --- /dev/null +++ b/feature/repository/src/test/java/com/devmike/repository/testdouble/RepositorySearchDouble.kt @@ -0,0 +1,148 @@ +package com.devmike.repository.testdouble + +import androidx.paging.PagingData +import com.devmike.domain.models.RepositoryModel +import com.devmike.domain.repository.IRepoSearchRepository +import com.devmike.repository.utils.createTestPagingFlow +import kotlinx.coroutines.flow.Flow + +class RepositorySearchDouble : IRepoSearchRepository { + private val fakeRepositories: List + get() = fakeFlutterRepositories + fakeRustRepositories + + override fun searchRepositories(query: String): Flow> { + val items = fakeRepositories.filter { it.nameWithOwner.contains(query, ignoreCase = true) } + + return createTestPagingFlow(items) + } +} + +val fakeFlutterRepositories = + listOf( + RepositoryModel( + url = "https://github.com/flutter/flutter", + name = "flutter", + nameWithOwner = "flutter/flutter", + owner = "flutter", + description = + "Flutter makes it easy and fast to " + + "build beautiful apps for mobile and beyond", + stargazers = 165315, + forkCount = 27277, + issueCount = 99037, + avatarUrl = "https://avatars.githubusercontent.com/u/14101776?v=4", + ), + RepositoryModel( + url = "https://github.com/flutter/plugins", + name = "plugins", + nameWithOwner = "flutter/plugins", + owner = "flutter", + description = "Plugins for Flutter maintained by the Flutter team", + stargazers = 17467, + forkCount = 9806, + issueCount = 0, + avatarUrl = "https://avatars.githubusercontent.com/u/14101776?v=4", + ), + RepositoryModel( + url = "https://github.com/iampawan/FlutterExampleApps", + name = "FlutterExampleApps", + nameWithOwner = "iampawan/FlutterExampleApps", + owner = "iampawan", + description = "[Example APPS] Basic Flutter apps, for flutter devs.", + stargazers = 20489, + forkCount = 3768, + issueCount = 41, + avatarUrl = + "https://avatars.githubusercontent.com/u" + + "/12619420?u=a49ba4b7f5ae93afc2febc86a021b42d2f5b5858&v=4", + ), + RepositoryModel( + url = "https://github.com/Solido/awesome-flutter", + name = "awesome-flutter", + nameWithOwner = "Solido/awesome-flutter", + owner = "Solido", + description = + "An awesome list that curates the best Flutter libraries," + + " tools, tutorials, articles and more.", + stargazers = 53256, + forkCount = 6647, + issueCount = 0, + avatarUrl = + "https://avatars.githubusercontent.com/u/" + + "1295961?u=b85eaeb98c4c24aeec8046b7839a5ddf2d504289&v=4", + ), + RepositoryModel( + url = "https://github.com/flutter/engine", + name = "engine", + nameWithOwner = "flutter/engine", + owner = "flutter", + description = "The Flutter engine", + stargazers = 7376, + forkCount = 5948, + issueCount = 0, + avatarUrl = "https://avatars.githubusercontent.com/u/14101776?v=4", + ), + ) + +val fakeRustRepositories = + listOf( + RepositoryModel( + url = "https://github.com/rust-lang/rust", + name = "rust", + nameWithOwner = "rust-lang/rust", + owner = "rust-lang", + description = "Empowering everyone to build reliable and efficient software.", + stargazers = 97402, + forkCount = 12591, + issueCount = 54709, + avatarUrl = "https://avatars.githubusercontent.com/u/5430905?v=4", + ), + RepositoryModel( + url = "https://github.com/TheAlgorithms/Rust", + name = "Rust", + nameWithOwner = "TheAlgorithms/Rust", + owner = "TheAlgorithms", + description = " All Algorithms implemented in Rust ", + stargazers = 22465, + forkCount = 2188, + issueCount = 69, + avatarUrl = "https://avatars.githubusercontent.com/u/20487725?v=4", + ), + RepositoryModel( + url = "https://github.com/rustdesk/rustdesk", + name = "rustdesk", + nameWithOwner = "rustdesk/rustdesk", + owner = "rustdesk", + description = + "An open-source remote desktop application designed for self-hosting," + + " as an alternative to TeamViewer.", + stargazers = 73719, + forkCount = 8945, + issueCount = 3071, + avatarUrl = + "https://avatars.githubusercontent.com/" + + "u/71636191?u=fcdfa5bbe724bd4ec02f6c3b2419ff25b7f5eb07&v=4", + ), + RepositoryModel( + url = "https://github.com/rust-lang/rustlings", + name = "rustlings", + nameWithOwner = "rust-lang/rustlings", + owner = "rust-lang", + description = "Small exercises to get you used to reading and writing Rust code!", + stargazers = 53227, + forkCount = 10061, + issueCount = 663, + avatarUrl = "https://avatars.githubusercontent.com/u/5430905?v=4", + ), + RepositoryModel( + url = "https://github.com/tensorflow/rust", + name = "rust", + nameWithOwner = "tensorflow/rust", + owner = "tensorflow", + description = "Rust language bindings for TensorFlow", + stargazers = 5140, + forkCount = 422, + issueCount = 189, + avatarUrl = "https://avatars.githubusercontent.com/u/15658638?v=4", + ), + ) diff --git a/feature/repository/src/test/java/com/devmike/repository/uitest/RepositorySearchTest.kt b/feature/repository/src/test/java/com/devmike/repository/uitest/RepositorySearchTest.kt new file mode 100644 index 0000000..e2e6034 --- /dev/null +++ b/feature/repository/src/test/java/com/devmike/repository/uitest/RepositorySearchTest.kt @@ -0,0 +1,105 @@ +package com.devmike.repository.uitest + +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertAny +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.devmike.domain.repository.IRepoSearchRepository +import com.devmike.repository.screen.RepositorySearchScreen +import com.devmike.repository.screen.RepositorySearchViewModel +import com.devmike.repository.screen.components.RepositoryListScreen +import com.devmike.repository.testdouble.RepositorySearchDouble +import com.devmike.repository.testdouble.fakeRustRepositories +import com.devmike.repository.utils.createTestPagingFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RepositorySearchTest { + @get:Rule + val composeTestRule = createComposeRule() + + lateinit var viewModel: RepositorySearchViewModel + + @Before + fun setup() { + val fakeRepo: IRepoSearchRepository = RepositorySearchDouble() + viewModel = RepositorySearchViewModel(fakeRepo) + } + + @Test + fun `Idle Screen Is Visible when no search query is provided`() { + composeTestRule.setContent { + RepositorySearchScreen(viewModel, {}, { _, _ -> }) + } + + composeTestRule.onNodeWithText("Type at least 3 characters to search").assertExists() + } + + @Test + fun `Search Screen Is Visible when search query is provided`() { + viewModel.modifySearchQuery("Android") + + composeTestRule.setContent { + RepositorySearchScreen(viewModel, {}, { _, _ -> }) + } + + composeTestRule.onNodeWithTag("repositorieslist").assertExists() + } + + @Test + fun repositoryItemsSreVisibleWhenPagingItemsAreProvided() { + val items = createTestPagingFlow(fakeRustRepositories) + + composeTestRule.setContent { + RepositoryListScreen( + modifier = Modifier, + repositoriesState = items.collectAsLazyPagingItems(), + ) { _, _ -> + } + } + + composeTestRule + .onAllNodesWithTag( + "repository_item", + ).assertAny(hasText("rust", substring = true)) + } + + @Test + fun `Tapping on logout icon shows the logout dialog`() { + composeTestRule.setContent { + RepositorySearchScreen(viewModel, {}, { _, _ -> }) + } + + composeTestRule.onNodeWithTag("logout_button").performClick() + + composeTestRule.onNodeWithTag("Logout_Dialog").assertExists() + + composeTestRule.onNodeWithText("Logout Confirmation").assertExists() + } + + @Test + fun itemsaredisplayedforacorrectsearchquery() { + // viewModel.modifySearchQuery("flutter") + composeTestRule.setContent { + RepositorySearchScreen(viewModel, {}, { _, _ -> }) + } + composeTestRule.onNodeWithTag("search_repositories").performTextInput("flutter") + // Allow time for debounce and collection + composeTestRule.mainClock.advanceTimeBy(500) + + composeTestRule + .onAllNodesWithTag( + "repository_item", + ).assertAny(hasText("flutter", substring = true)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8e38a6e..e64423f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ apollo = "4.0.0" apolloMockserver = "0.0.3" apolloTestingSupport = "4.0.0" appauth = "0.11.1" +hiltAndroidTesting = "2.51.1" kotlin = "2.0.0" coreKtx = "1.13.1" junit = "4.13.2" @@ -65,6 +66,7 @@ androidx-paging-testing = { module = "androidx.paging:paging-testing", version.r apollo-mockserver = { module = "com.apollographql.mockserver:apollo-mockserver", version.ref = "apolloMockserver" } apollo-testing-support = { module = "com.apollographql.apollo:apollo-testing-support", version.ref = "apolloTestingSupport" } appauth = { module = "net.openid:appauth", version.ref = "appauth" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hiltAndroidTesting" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -144,11 +146,12 @@ firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashly firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics", version.ref = "firebaseAnalytics" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } +ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } [bundles] compose = ["androidx-activity-compose","androidx-compose-runtime","androidx-material3","androidx-compose-foundation","androidx-compose-foundation-layout", "androidx-compose-material-iconsExtended","androidx-compose-ui-util","lottie-compose","androidx-navigation-compose","androidx-ui", - "androidx-ui-tooling","androidx-ui-tooling-preview","androidx-ui-test-manifest","androidx-hilt-navigation-compose","androidx-lifecycle-viewmodel-compose"] + "androidx-ui-tooling","androidx-ui-tooling-preview","androidx-ui-test-manifest","androidx-hilt-navigation-compose","androidx-lifecycle-viewmodel-compose","androidx-ui-test-junit4"] [plugins] android-application = { id = "com.android.application", version.ref = "agp" }