diff --git a/.github/scripts/post_test_report.py b/.github/scripts/post_test_report.py new file mode 100644 index 000000000..fd91be59c --- /dev/null +++ b/.github/scripts/post_test_report.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Parse JUnit XML test results and print failed tests to stdout. + +Prints nothing when all tests pass. Prints only failed tests when failures exist, +so the caller can decide whether to post a PR comment based on whether stdout is non-empty. +""" +import glob +import sys +import xml.etree.ElementTree as ET + + +def main() -> None: + title = sys.argv[1] + patterns = sys.argv[2:] + + paths = [] + for pattern in patterns: + paths.extend(sorted(glob.glob(pattern, recursive=True))) + + if not paths: + return + + failed_by_class: dict[str, list[str]] = {} + total = failed = 0 + + for path in paths: + root = ET.parse(path).getroot() + for case in root.findall("testcase"): + total += 1 + if case.find("failure") is not None or case.find("error") is not None: + failed += 1 + classname = case.get("classname", root.get("name", path)) + failed_by_class.setdefault(classname, []).append( + f"- ❌ {case.get('name')}" + ) + + if failed == 0: + return + + print(f"## ❌ {title} — {total}개 중 {failed}개 실패\n") + for classname in sorted(failed_by_class): + print(f"### {classname}") + print("\n".join(failed_by_class[classname])) + print() + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1f0b65c95..d99b53971 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -5,8 +5,13 @@ on: branches: [ develop ] pull_request: - branches: [ develop ] - + +permissions: + contents: read + actions: read + checks: write + pull-requests: write + defaults: run: shell: bash @@ -20,10 +25,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: set up JDK 17 + - name: set up JDK 21 uses: actions/setup-java@v4 with: - java-version: 17 + java-version: 21 distribution: temurin cache: gradle @@ -99,5 +104,158 @@ jobs: - name: Change gradlew permissions run: chmod +x ./gradlew - - name: Build + - name: Run unit tests + run: ./gradlew testDebugUnitTest --stacktrace + + - name: Comment unit test failures on PR + if: always() && github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + MARKER="" + REPORT="$(python3 .github/scripts/post_test_report.py 'Unit Test Results' 'app/build/test-results/testDebugUnitTest/*.xml')" + existing_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1) + if [ -n "$REPORT" ]; then + BODY="$MARKER"$'\n'"$REPORT" + if [ -n "$existing_id" ]; then + gh api "repos/$REPO/issues/comments/$existing_id" -X PATCH -f body="$BODY" + else + gh api "repos/$REPO/issues/$PR_NUMBER/comments" -X POST -f body="$BODY" + fi + elif [ -n "$existing_id" ]; then + gh api "repos/$REPO/issues/comments/$existing_id" -X DELETE + fi + + - name: Build run: ./gradlew assembleDebug --stacktrace + + android-test: + name: Run Compose UI Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: temurin + cache: gradle + + - name: Write compile time google-services.json file + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + run: echo $GOOGLE_SERVICES_JSON > app/google-services.json + + - name: Touch local properties + run: touch local.properties + + - name: Access NAVER_CLIENT_ID + env: + NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }} + run: echo "NAVER_CLIENT_ID = \"$NAVER_CLIENT_ID\"" >> local.properties + + - name: Access RUNNECT_NODE_URL + env: + RUNNECT_NODE_URL: ${{ secrets.RUNNECT_NODE_URL }} + run: echo "RUNNECT_NODE_URL=\"$RUNNECT_NODE_URL\"" >> local.properties + + - name: Access RUNNECT_DEV_URL + env: + RUNNECT_DEV_URL: ${{ secrets.RUNNECT_DEV_URL }} + run: echo "RUNNECT_DEV_URL=\"$RUNNECT_DEV_URL\"" >> local.properties + + - name: Access RUNNECT_PROD_URL + env: + RUNNECT_PROD_URL: ${{ secrets.RUNNECT_PROD_URL }} + run: echo "RUNNECT_PROD_URL=\"$RUNNECT_PROD_URL\"" >> local.properties + + - name: Access TMAP_BASE_URL + env: + TMAP_BASE_URL: ${{ secrets.TMAP_BASE_URL }} + run: echo "TMAP_BASE_URL=\"$TMAP_BASE_URL\"" >> local.properties + + - name: Access TMAP_API_KEY + env: + TMAP_API_KEY: ${{ secrets.TMAP_API_KEY }} + run: echo "TMAP_API_KEY=\"$TMAP_API_KEY\"" >> local.properties + + - name: Access GOOGLE_CLIENT_ID + env: + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + run: echo "GOOGLE_CLIENT_ID=\"$GOOGLE_CLIENT_ID\"" >> local.properties + + - name: Access REMOTE_KEY_APP_VERSION + env: + REMOTE_KEY_APP_VERSION: ${{ secrets.REMOTE_KEY_APP_VERSION }} + run: echo "REMOTE_KEY_APP_VERSION=\"$REMOTE_KEY_APP_VERSION\"" >> local.properties + + - name: Access KAKAO_CHANNEL_ID + env: + KAKAO_CHANNEL_ID: ${{ secrets.KAKAO_CHANNEL_ID }} + run: echo "KAKAO_CHANNEL_ID=\"$KAKAO_CHANNEL_ID\"" >> local.properties + + - name: Access REMOTE_KEY_FORCE_UPDATE + env: + REMOTE_KEY_FORCE_UPDATE: ${{ secrets.REMOTE_KEY_FORCE_UPDATE }} + run: echo "REMOTE_KEY_FORCE_UPDATE=\"$REMOTE_KEY_FORCE_UPDATE\"" >> local.properties + + - name: Add kakao_strings.xml + env: + KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + KAKAO_REDIRECTION_SCHEME: ${{ secrets.KAKAO_REDIRECTION_SCHEME }} + run: | + echo '' > app/src/main/res/values/kakao_strings.xml + echo '' >> app/src/main/res/values/kakao_strings.xml + echo ' $KAKAO_NATIVE_APP_KEY' >> app/src/main/res/values/kakao_strings.xml + echo ' $KAKAO_REDIRECTION_SCHEME' >> app/src/main/res/values/kakao_strings.xml + echo '' >> app/src/main/res/values/kakao_strings.xml + + - name: Change gradlew permissions + run: chmod +x ./gradlew + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run Compose UI tests on emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_6 + script: ./gradlew connectedDebugAndroidTest --stacktrace + + - name: Comment Compose UI test failures on PR + if: always() && github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + MARKER="" + REPORT="$(python3 .github/scripts/post_test_report.py 'Compose UI Test Results' 'app/build/outputs/androidTest-results/connected/**/*.xml')" + existing_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1) + if [ -n "$REPORT" ]; then + BODY="$MARKER"$'\n'"$REPORT" + if [ -n "$existing_id" ]; then + gh api "repos/$REPO/issues/comments/$existing_id" -X PATCH -f body="$BODY" + else + gh api "repos/$REPO/issues/$PR_NUMBER/comments" -X POST -f body="$BODY" + fi + elif [ -n "$existing_id" ]; then + gh api "repos/$REPO/issues/comments/$existing_id" -X DELETE + fi + + - name: Upload instrumented test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-test-results + path: app/build/outputs/androidTest-results/connected/** diff --git a/app/build.gradle b/app/build.gradle index 42145f303..eaafa88ee 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -99,6 +99,12 @@ android { } } +tasks.withType(Test).configureEach { + testLogging { + events "passed", "skipped", "failed" + } +} + tasks.register('renameReleaseBundle') { def vName = libs.versions.versionName.get() def bundlePath = layout.buildDirectory.dir("outputs/bundle/release") @@ -191,6 +197,7 @@ dependencies { testImplementation libs.mockk testImplementation libs.turbine testImplementation libs.kotlinx.coroutines.test + testImplementation libs.androidx.core.testing androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.espresso.core } diff --git a/app/src/androidTest/java/com/runnect/runnect/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/runnect/runnect/ExampleInstrumentedTest.kt index 927d73c4c..b510dab40 100644 --- a/app/src/androidTest/java/com/runnect/runnect/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/runnect/runnect/ExampleInstrumentedTest.kt @@ -19,6 +19,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.example.runnect", appContext.packageName) + assertEquals("com.runnect.runnect", appContext.packageName) } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/runnect/runnect/presentation/storage/StorageScrapScreenTest.kt b/app/src/androidTest/java/com/runnect/runnect/presentation/storage/StorageScrapScreenTest.kt new file mode 100644 index 000000000..c94c72df9 --- /dev/null +++ b/app/src/androidTest/java/com/runnect/runnect/presentation/storage/StorageScrapScreenTest.kt @@ -0,0 +1,109 @@ +package com.runnect.runnect.presentation.storage + +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasProgressBarRangeInfo +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.runnect.runnect.domain.entity.MyScrapCourse +import com.runnect.runnect.presentation.ui.theme.RunnectTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class StorageScrapScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun course(title: String) = MyScrapCourse( + courseId = 1, + id = 1, + publicCourseId = 10, + image = null, + city = "서울", + region = "강남", + title = title + ) + + @Test + fun `카드탭_시_onScrapItemClick이_해당_코스로_호출된다`() { + val testCourse = course(title = "테스트 코스") + var clicked: MyScrapCourse? = null + + composeTestRule.setContent { + RunnectTheme { + StorageScrapScreen( + state = StorageScrapUiState(courses = listOf(testCourse)), + onRefresh = {}, + onScrapItemClick = { clicked = it }, + onHeartClick = {}, + onGoToScrapClick = {}, + onErrorShown = {} + ) + } + } + + composeTestRule.onNodeWithText("테스트 코스").performClick() + + assertEquals(testCourse, clicked) + } + + @Test + fun `로딩_중에는_스크랩이_없어도_빈_화면이_보이지_않는다`() { + composeTestRule.setContent { + RunnectTheme { + StorageScrapScreen( + state = StorageScrapUiState(courses = emptyList(), isLoading = true), + onRefresh = {}, + onScrapItemClick = {}, + onHeartClick = {}, + onGoToScrapClick = {}, + onErrorShown = {} + ) + } + } + + composeTestRule.onNodeWithText("아직 스크랩한 코스가 없어요", substring = true) + .assertDoesNotExist() + } + + @Test + fun `로딩이_끝나고_스크랩이_없으면_빈_화면이_보인다`() { + composeTestRule.setContent { + RunnectTheme { + StorageScrapScreen( + state = StorageScrapUiState(courses = emptyList(), isLoading = false), + onRefresh = {}, + onScrapItemClick = {}, + onHeartClick = {}, + onGoToScrapClick = {}, + onErrorShown = {} + ) + } + } + + composeTestRule.onNodeWithText("아직 스크랩한 코스가 없어요", substring = true) + .assertIsDisplayed() + } + + @Test + fun `isLoading이_true이면_로딩_인디케이터가_보인다`() { + composeTestRule.setContent { + RunnectTheme { + StorageScrapScreen( + state = StorageScrapUiState(courses = listOf(course("코스")), isLoading = true), + onRefresh = {}, + onScrapItemClick = {}, + onHeartClick = {}, + onGoToScrapClick = {}, + onErrorShown = {} + ) + } + } + + composeTestRule.onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)) + .assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt index a45e0b8a3..5d9446bd8 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt @@ -1,119 +1,111 @@ package com.runnect.runnect.presentation.storage -import android.content.ContentValues import android.content.Intent import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import androidx.core.view.isVisible +import android.view.ViewGroup +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.GridLayoutManager import com.runnect.runnect.R -import com.runnect.runnect.binding.BindingFragment import com.runnect.runnect.domain.entity.MyScrapCourse -import com.runnect.runnect.databinding.FragmentStorageScrapBinding import com.runnect.runnect.presentation.MainActivity import com.runnect.runnect.presentation.detail.CourseDetailActivity +import com.runnect.runnect.presentation.detail.CourseDetailRootScreen import com.runnect.runnect.presentation.event.ScreenRefreshEvent import com.runnect.runnect.presentation.event.ScreenRefreshEventBus -import com.runnect.runnect.presentation.detail.CourseDetailRootScreen import com.runnect.runnect.presentation.state.UiStateV2 -import com.runnect.runnect.presentation.storage.adapter.StorageScrapAdapter +import com.runnect.runnect.presentation.ui.theme.RunnectTheme import com.runnect.runnect.util.analytics.Analytics import com.runnect.runnect.util.analytics.EventName import com.runnect.runnect.util.analytics.EventName.Param -import com.runnect.runnect.util.custom.deco.GridSpacingItemDecoration -import com.runnect.runnect.util.callback.ItemCount -import com.runnect.runnect.util.callback.listener.OnHeartButtonClick -import com.runnect.runnect.util.callback.listener.OnScrapItemClick -import com.runnect.runnect.util.extension.showSnackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint -class StorageScrapFragment : - BindingFragment(R.layout.fragment_storage_scrap), - OnHeartButtonClick, - OnScrapItemClick, - ItemCount { +class StorageScrapFragment : Fragment() { @Inject lateinit var screenRefreshEventBus: ScreenRefreshEventBus - val viewModel: StorageViewModel by viewModels() - private lateinit var storageScrapAdapter: StorageScrapAdapter + private val viewModel: StorageViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + RunnectTheme { + val getState by viewModel.myScrapCourseGetState.observeAsState() + val scrapState by viewModel.courseScrapState.observeAsState() + + var courses by remember { mutableStateOf(emptyList()) } + var errorMessage by remember { mutableStateOf(null) } + + LaunchedEffect(getState) { + when (val current = getState) { + is UiStateV2.Success -> { + courses = current.data + Analytics.logEvent( + EventName.VIEW_STORAGE_SCRAP, + Param.COURSE_COUNT to current.data.size + ) + } + + is UiStateV2.Failure -> errorMessage = current.msg + else -> Unit + } + } + + LaunchedEffect(scrapState) { + when (val current = scrapState) { + is UiStateV2.Success -> { + courses = courses.filterNot { + it.publicCourseId.toLong() == current.data.publicCourseId + } + } + + is UiStateV2.Failure -> errorMessage = current.msg + else -> Unit + } + } + + StorageScrapScreen( + state = StorageScrapUiState.from( + getState = getState, + scrapState = scrapState, + courses = courses, + errorMessage = errorMessage + ), + onRefresh = { viewModel.getMyScrapCourses() }, + onScrapItemClick = { course -> navigateToCourseDetail(course) }, + onHeartClick = { course -> + viewModel.postCourseScrap(id = course.publicCourseId, scrapTF = false) + }, + onGoToScrapClick = { navigateToDiscover() }, + onErrorShown = { errorMessage = null } + ) + } + } + } + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.lifecycleOwner = viewLifecycleOwner - Analytics.logEvent(EventName.VIEW_STORAGE_SCRAP) - getMyScrapCourses() - initLayout() - initAdapter() - addListener() - addObserver() - } - - fun getMyScrapCourses() { viewModel.getMyScrapCourses() - } - - private fun initLayout() { - binding.rvStorageScrap.apply { - val context = context ?: return - layoutManager = GridLayoutManager(context, 2) - addItemDecoration( - GridSpacingItemDecoration( - context = context, - spanCount = 2, - horizontalSpaceSize = 6, - topSpaceSize = 20 - ) - ) - } - } - - private fun initAdapter() { - storageScrapAdapter = StorageScrapAdapter( - onScrapItemClick = this, - onHeartButtonClick = this, - itemCount = this - ).apply { - binding.rvStorageScrap.adapter = this - } - } - - private fun addListener() { - initGoToScrapButtonClickListener() - initRefreshLayoutListener() - } - - private fun initGoToScrapButtonClickListener() { - binding.btnStorageNoScrap.setOnClickListener { - val intent = Intent(activity, MainActivity::class.java).apply { - putExtra(EXTRA_FRAGMENT_REPLACEMENT_DIRECTION, "fromMyScrap") - } - startActivity(intent) - requireActivity().overridePendingTransition( - R.anim.slide_in_right, - R.anim.slide_out_left - ) - } - } - - private fun initRefreshLayoutListener() { - binding.refreshLayout.setOnRefreshListener { - getMyScrapCourses() - binding.refreshLayout.isRefreshing = false - } - } - - private fun addObserver() { - setupItemSizeObserver() - setupMyScrapCourseGetStateObserver() - setupCourseScrapStateObserver() collectScreenRefreshEvents() } @@ -121,107 +113,28 @@ class StorageScrapFragment : viewLifecycleOwner.lifecycleScope.launch { screenRefreshEventBus.events.collect { event -> if (event is ScreenRefreshEvent.RefreshStorageScrap) { - getMyScrapCourses() + viewModel.getMyScrapCourses() } } } } - private fun setupCourseScrapStateObserver() { - viewModel.courseScrapState.observe(viewLifecycleOwner) { state -> - when (state) { - is UiStateV2.Loading -> { - showLoadingProgressBar() - } - - is UiStateV2.Success -> { - dismissLoadingProgressBar() - storageScrapAdapter.removeCourseItem() - } - - is UiStateV2.Failure -> { - dismissLoadingProgressBar() - context?.showSnackbar( - anchorView = binding.root, - message = state.msg - ) - } - - else -> {} - } - } - } - - private fun setupItemSizeObserver() { - viewModel.itemSize.observe(viewLifecycleOwner) { itemSize -> - val isEmpty = (itemSize == 0) - updateEmptyView(isEmpty, itemSize) + private fun navigateToCourseDetail(course: MyScrapCourse) { + val intent = Intent(activity, CourseDetailActivity::class.java).apply { + putExtra(EXTRA_PUBLIC_COURSE_ID, course.publicCourseId) + putExtra(EXTRA_ROOT_SCREEN, CourseDetailRootScreen.COURSE_STORAGE_SCRAP) } + startActivity(intent) + requireActivity().overridePendingTransition( + R.anim.slide_in_right, + R.anim.slide_out_left + ) } - private fun updateEmptyView(isEmpty: Boolean, itemSize: Int) { - binding.apply { - clMyDrawNoScrap.isVisible = isEmpty - rvStorageScrap.isVisible = !isEmpty - tvStorageScrapCount.isVisible = !isEmpty - tvStorageScrapCount.text = if (!isEmpty) "총 코스 ${itemSize}개" else "" + private fun navigateToDiscover() { + val intent = Intent(activity, MainActivity::class.java).apply { + putExtra(EXTRA_FRAGMENT_REPLACEMENT_DIRECTION, "fromMyScrap") } - } - - private fun setupMyScrapCourseGetStateObserver() { - viewModel.myScrapCourseGetState.observe(viewLifecycleOwner) { state -> - when (state) { - is UiStateV2.Loading -> { - showLoadingProgressBar() - } - - is UiStateV2.Success -> { - dismissLoadingProgressBar() - - val scrapCourses = state.data - updateEmptyView(scrapCourses.isEmpty(), scrapCourses.size) - storageScrapAdapter.submitList(scrapCourses) - Analytics.logEvent( - EventName.VIEW_STORAGE_SCRAP, - Param.COURSE_COUNT to scrapCourses.size - ) - } - - is UiStateV2.Failure -> { - dismissLoadingProgressBar() - context?.showSnackbar( - anchorView = binding.root, - message = state.msg - ) - } - - else -> {} - } - } - } - - private fun showLoadingProgressBar() { - binding.pbStorageScrapLoading.isVisible = true - } - - private fun dismissLoadingProgressBar() { - binding.pbStorageScrapLoading.isVisible = false - } - - override fun calcItemSize(itemCount: Int) { - viewModel.itemSize.value = itemCount - } - - override fun scrapCourse(id: Int, scrapTF: Boolean) { - viewModel.postCourseScrap(id, scrapTF) - } - - override fun selectItem(item: MyScrapCourse) { - Timber.tag(ContentValues.TAG).d("코스 아이디 : ${item.publicCourseId}") - - val intent = Intent(activity, CourseDetailActivity::class.java) - intent.putExtra(EXTRA_PUBLIC_COURSE_ID, item.publicCourseId) - intent.putExtra(EXTRA_ROOT_SCREEN, CourseDetailRootScreen.COURSE_STORAGE_SCRAP) startActivity(intent) requireActivity().overridePendingTransition( R.anim.slide_in_right, diff --git a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt new file mode 100644 index 000000000..a160a33e9 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt @@ -0,0 +1,232 @@ +package com.runnect.runnect.presentation.storage + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.runnect.runnect.R +import com.runnect.runnect.domain.entity.MyScrapCourse +import com.runnect.runnect.domain.entity.PostScrap +import com.runnect.runnect.presentation.state.UiStateV2 +import com.runnect.runnect.presentation.ui.theme.G1 +import com.runnect.runnect.presentation.ui.theme.G2 +import com.runnect.runnect.presentation.ui.theme.G4 +import com.runnect.runnect.presentation.ui.theme.M1 +import com.runnect.runnect.presentation.ui.theme.RunnectTheme +import com.runnect.runnect.presentation.ui.theme.White + +data class StorageScrapUiState( + val courses: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null +) { + companion object { + fun from( + getState: UiStateV2>?, + scrapState: UiStateV2?, + courses: List, + errorMessage: String?, + ) = StorageScrapUiState( + courses = courses, + isLoading = getState is UiStateV2.Loading || scrapState is UiStateV2.Loading, + errorMessage = errorMessage + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StorageScrapScreen( + state: StorageScrapUiState, + onRefresh: () -> Unit, + onScrapItemClick: (MyScrapCourse) -> Unit, + onHeartClick: (MyScrapCourse) -> Unit, + onGoToScrapClick: () -> Unit, + onErrorShown: () -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state.errorMessage) { + state.errorMessage?.let { + snackbarHostState.showSnackbar(it) + onErrorShown() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + PullToRefreshBox( + isRefreshing = state.isLoading, + onRefresh = onRefresh, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (!state.isLoading && state.courses.isEmpty()) { + EmptyScrapView(onGoToScrapClick = onGoToScrapClick) + } else { + Column(modifier = Modifier.fillMaxSize()) { + ScrapCountHeader(count = state.courses.size) + LazyVerticalGrid( + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(horizontal = 15.dp, vertical = 20.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + modifier = Modifier.fillMaxSize() + ) { + items(state.courses, key = { it.id }) { course -> + ScrapCourseItem( + course = course, + onClick = { onScrapItemClick(course) }, + onHeartClick = { onHeartClick(course) } + ) + } + } + } + } + } + } +} + +@Composable +private fun ScrapCountHeader(count: Int) { + val textStyle = RunnectTheme.textStyle + Box( + modifier = Modifier + .fillMaxWidth() + .background(White) + .padding(horizontal = 16.dp, vertical = 7.dp) + ) { + Text( + text = stringResource(R.string.storage_total_course_count, count), + style = textStyle.regular12, + color = G2 + ) + } +} + +@Composable +private fun ScrapCourseItem( + course: MyScrapCourse, + onClick: () -> Unit, + onHeartClick: () -> Unit +) { + val textStyle = RunnectTheme.textStyle + Column(modifier = Modifier.clickable(onClick = onClick)) { + AsyncImage( + model = course.image, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(162f / 114f) + .clip(RoundedCornerShape(5.dp)) + .background(G4) + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = course.title, + style = textStyle.medium14, + color = G1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.size(4.dp)) + Box( + modifier = Modifier + .size(21.dp, 18.dp) + .clickable(onClick = onHeartClick) + ) { + Image( + painter = painterResource(R.drawable.discover_course_scrap_on), + contentDescription = null + ) + } + } + Text( + text = "${course.city} ${course.region}", + style = textStyle.regular12, + color = G2 + ) + } +} + +@Composable +private fun EmptyScrapView(onGoToScrapClick: () -> Unit) { + val textStyle = RunnectTheme.textStyle + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 64.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.no_course), + contentDescription = null + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.storage_scrap_empty_guide), + style = textStyle.medium14, + color = G2, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(22.dp)) + Button( + onClick = onGoToScrapClick, + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + colors = ButtonDefaults.buttonColors( + containerColor = M1, + contentColor = White + ), + shape = RoundedCornerShape(10.dp) + ) { + Text( + text = stringResource(R.string.storage_scrap_make_scrap), + style = textStyle.semiBold15 + ) + } + } +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/storage/adapter/StorageScrapAdapter.kt b/app/src/main/java/com/runnect/runnect/presentation/storage/adapter/StorageScrapAdapter.kt deleted file mode 100644 index 14c31d292..000000000 --- a/app/src/main/java/com/runnect/runnect/presentation/storage/adapter/StorageScrapAdapter.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.runnect.runnect.presentation.storage.adapter - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.runnect.runnect.domain.entity.MyScrapCourse -import com.runnect.runnect.databinding.ItemStorageScrapBinding -import com.runnect.runnect.util.callback.ItemCount -import com.runnect.runnect.util.callback.diff.ItemDiffCallback -import com.runnect.runnect.util.callback.listener.OnHeartButtonClick -import com.runnect.runnect.util.callback.listener.OnScrapItemClick - -class StorageScrapAdapter( - private val onScrapItemClick: OnScrapItemClick, - private val onHeartButtonClick: OnHeartButtonClick, - private val itemCount: ItemCount -) : ListAdapter(diffUtil) { - private var clickedItemPosition = -1 - - inner class ItemViewHolder(val binding: ItemStorageScrapBinding) : - RecyclerView.ViewHolder(binding.root) { - fun onBind(course: MyScrapCourse) { - binding.storageScrap = course - binding.ivItemStorageScrapHeart.isSelected = true - - initCourseItemClickListener(binding.root, course) - initHeartButtonClickListener(binding.ivItemStorageScrapHeart, course) - } - - private fun initCourseItemClickListener(view: View, course: MyScrapCourse) { - view.setOnClickListener { - onScrapItemClick.selectItem(course) - } - } - - private fun initHeartButtonClickListener(view: ImageView, course: MyScrapCourse) { - view.setOnClickListener { - clickedItemPosition = absoluteAdapterPosition - onHeartButtonClick.scrapCourse(course.publicCourseId, !it.isSelected) - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { - val inflater = LayoutInflater.from(parent.context) - val binding = ItemStorageScrapBinding.inflate(inflater) - return ItemViewHolder(binding) - } - - override fun onBindViewHolder(holder: ItemViewHolder, position: Int) { - holder.onBind(currentList[position]) - } - - fun removeCourseItem() { - if (clickedItemPosition == -1) return - val newList = currentList.toMutableList() - newList.removeAt(clickedItemPosition) - submitList(newList) - itemCount.calcItemSize(newList.size) - } - - companion object { - private val diffUtil = ItemDiffCallback( - onItemsTheSame = { old, new -> old.publicCourseId == new.publicCourseId }, - onContentsTheSame = { old, new -> old == new } - ) - } -} diff --git a/app/src/main/java/com/runnect/runnect/util/callback/listener/OnHeartButtonClick.kt b/app/src/main/java/com/runnect/runnect/util/callback/listener/OnHeartButtonClick.kt deleted file mode 100644 index f088757fd..000000000 --- a/app/src/main/java/com/runnect/runnect/util/callback/listener/OnHeartButtonClick.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.runnect.runnect.util.callback.listener - -interface OnHeartButtonClick { - fun scrapCourse(id: Int, scrapTF:Boolean) -} \ No newline at end of file diff --git a/app/src/main/java/com/runnect/runnect/util/callback/listener/OnScrapItemClick.kt b/app/src/main/java/com/runnect/runnect/util/callback/listener/OnScrapItemClick.kt deleted file mode 100644 index 173a9b65b..000000000 --- a/app/src/main/java/com/runnect/runnect/util/callback/listener/OnScrapItemClick.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.runnect.runnect.util.callback.listener - -import com.runnect.runnect.domain.entity.MyScrapCourse - -interface OnScrapItemClick { - fun selectItem(item: MyScrapCourse) -} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_storage_scrap.xml b/app/src/main/res/layout/fragment_storage_scrap.xml deleted file mode 100644 index d6d0176ce..000000000 --- a/app/src/main/res/layout/fragment_storage_scrap.xml +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_storage_scrap.xml b/app/src/main/res/layout/item_storage_scrap.xml deleted file mode 100644 index 1da4408ef..000000000 --- a/app/src/main/res/layout/item_storage_scrap.xml +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 376984363..eed4c21b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -66,6 +66,8 @@ 스크랩 코스 코스 그리기 스크랩하기 + 총 코스 %1$d개 + 아직 스크랩한 코스가 없어요\n 코스를 스크랩 해주세요 시작하기 총 거리 km diff --git a/app/src/test/java/com/runnect/runnect/presentation/storage/StorageScrapUiStateTest.kt b/app/src/test/java/com/runnect/runnect/presentation/storage/StorageScrapUiStateTest.kt new file mode 100644 index 000000000..a4fa8f35a --- /dev/null +++ b/app/src/test/java/com/runnect/runnect/presentation/storage/StorageScrapUiStateTest.kt @@ -0,0 +1,73 @@ +package com.runnect.runnect.presentation.storage + +import com.runnect.runnect.domain.entity.MyScrapCourse +import com.runnect.runnect.domain.entity.PostScrap +import com.runnect.runnect.presentation.state.UiStateV2 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StorageScrapUiStateTest { + + private fun course(title: String) = MyScrapCourse( + courseId = 1, + id = 1, + publicCourseId = 10, + image = null, + city = "서울", + region = "강남", + title = title + ) + + @Test + fun `목록_조회만_Loading이어도_isLoading은_true다`() { + val state = StorageScrapUiState.from( + getState = UiStateV2.Loading, + scrapState = null, + courses = emptyList(), + errorMessage = null + ) + + assertTrue(state.isLoading) + } + + @Test + fun `스크랩_토글만_Loading이어도_isLoading은_true다`() { + val state = StorageScrapUiState.from( + getState = UiStateV2.Success(emptyList()), + scrapState = UiStateV2.Loading, + courses = emptyList(), + errorMessage = null + ) + + assertTrue(state.isLoading) + } + + @Test + fun `둘다_Loading이_아니면_isLoading은_false다`() { + val state = StorageScrapUiState.from( + getState = UiStateV2.Success(emptyList()), + scrapState = UiStateV2.Success(PostScrap(publicCourseId = 1L, scrapCount = 0L, scrapTF = false)), + courses = emptyList(), + errorMessage = null + ) + + assertFalse(state.isLoading) + } + + @Test + fun `courses와_errorMessage는_그대로_전달된다`() { + val course = course(title = "테스트 코스") + + val state = StorageScrapUiState.from( + getState = UiStateV2.Failure("에러"), + scrapState = null, + courses = listOf(course), + errorMessage = "에러" + ) + + assertEquals(listOf(course), state.courses) + assertEquals("에러", state.errorMessage) + } +} diff --git a/app/src/test/java/com/runnect/runnect/presentation/storage/StorageViewModelTest.kt b/app/src/test/java/com/runnect/runnect/presentation/storage/StorageViewModelTest.kt new file mode 100644 index 000000000..8f498e81a --- /dev/null +++ b/app/src/test/java/com/runnect/runnect/presentation/storage/StorageViewModelTest.kt @@ -0,0 +1,222 @@ +package com.runnect.runnect.presentation.storage + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.runnect.runnect.data.dto.request.RequestPostCourseScrap +import com.runnect.runnect.data.dto.request.RequestPutMyDrawCourse +import com.runnect.runnect.domain.entity.MyDrawCourse +import com.runnect.runnect.domain.entity.MyScrapCourse +import com.runnect.runnect.domain.entity.PostScrap +import com.runnect.runnect.domain.repository.CourseRepository +import com.runnect.runnect.domain.repository.StorageRepository +import com.runnect.runnect.presentation.state.UiState +import com.runnect.runnect.presentation.state.UiStateV2 +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class StorageViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var storageRepository: StorageRepository + private lateinit var courseRepository: CourseRepository + private lateinit var viewModel: StorageViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + storageRepository = mockk() + courseRepository = mockk() + viewModel = StorageViewModel(storageRepository, courseRepository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun myDrawCourse(id: Int, title: String) = MyDrawCourse( + courseId = id, + image = null, + city = "서울", + region = "강남", + title = title + ) + + @Test + fun `getMyDrawList 성공 시 코스 목록과 상태가 갱신된다`() = runTest(testDispatcher) { + val courses = listOf(myDrawCourse(1, "코스1"), myDrawCourse(2, "코스2")) + coEvery { storageRepository.getMyDrawCourse() } returns flow { + delay(1) + emit(Result.success(courses)) + } + + val states = mutableListOf() + viewModel.myDrawCourseGetState.observeForever { states.add(it) } + + viewModel.getMyDrawList() + advanceUntilIdle() + + assertEquals(listOf(UiState.Empty, UiState.Loading, UiState.Success), states) + assertEquals(courses, viewModel.myDrawCourses) + } + + @Test + fun `getMyDrawList 실패 시 에러 메시지와 Failure 상태로 갱신된다`() = runTest(testDispatcher) { + coEvery { storageRepository.getMyDrawCourse() } returns flow { + delay(1) + emit(Result.failure(RuntimeException("네트워크 오류"))) + } + + val states = mutableListOf() + viewModel.myDrawCourseGetState.observeForever { states.add(it) } + + viewModel.getMyDrawList() + advanceUntilIdle() + + assertEquals(listOf(UiState.Empty, UiState.Loading, UiState.Failure), states) + assertEquals("네트워크 오류", viewModel.errorMessage.value) + } + + @Test + fun `deleteMyDrawCourse 성공 시 선택한 코스만 목록에서 제거된다`() = runTest(testDispatcher) { + val courses = listOf(myDrawCourse(1, "코스1"), myDrawCourse(2, "코스2")) + coEvery { storageRepository.getMyDrawCourse() } returns flow { emit(Result.success(courses)) } + coEvery { + storageRepository.deleteMyDrawCourse(RequestPutMyDrawCourse(courseIdList = listOf(1))) + } returns flow { + delay(1) + emit(Result.success(Unit)) + } + + viewModel.getMyDrawList() + advanceUntilIdle() + viewModel.modifyItemsToDelete(1) + + val states = mutableListOf() + viewModel.myDrawCourseDeleteState.observeForever { states.add(it) } + + viewModel.deleteMyDrawCourse() + advanceUntilIdle() + + assertEquals(listOf(UiState.Loading, UiState.Success), states) + assertEquals(listOf(courses[1]), viewModel.myDrawCourses) + } + + @Test + fun `deleteMyDrawCourse 실패 시 Failure 상태로 갱신된다`() = runTest(testDispatcher) { + coEvery { + storageRepository.deleteMyDrawCourse(RequestPutMyDrawCourse(courseIdList = listOf(1))) + } returns flow { + delay(1) + emit(Result.failure(RuntimeException("삭제 실패"))) + } + viewModel.modifyItemsToDelete(1) + + val states = mutableListOf() + viewModel.myDrawCourseDeleteState.observeForever { states.add(it) } + + viewModel.deleteMyDrawCourse() + advanceUntilIdle() + + assertEquals(listOf(UiState.Loading, UiState.Failure), states) + } + + @Test + fun `getMyScrapCourses 성공 시 스크랩 목록과 itemSize가 갱신된다`() = runTest(testDispatcher) { + val scrapCourses = listOf( + MyScrapCourse(courseId = 1, id = 1, publicCourseId = 10, image = null, city = "서울", region = "강남", title = "스크랩1") + ) + coEvery { storageRepository.getMyScrapCourse() } returns flow { + delay(1) + emit(Result.success(scrapCourses)) + } + + val states = mutableListOf>?>() + viewModel.myScrapCourseGetState.observeForever { states.add(it) } + + viewModel.getMyScrapCourses() + advanceUntilIdle() + + assertEquals(listOf(UiStateV2.Loading, UiStateV2.Success(scrapCourses)), states) + assertEquals(1, viewModel.itemSize.value) + } + + @Test + fun `getMyScrapCourses 실패 시 Failure 상태로 갱신된다`() = runTest(testDispatcher) { + coEvery { storageRepository.getMyScrapCourse() } returns flow { + delay(1) + emit(Result.failure(RuntimeException("스크랩 조회 실패"))) + } + + val states = mutableListOf>?>() + viewModel.myScrapCourseGetState.observeForever { states.add(it) } + + viewModel.getMyScrapCourses() + advanceUntilIdle() + + assertEquals(UiStateV2.Failure("스크랩 조회 실패"), states.last()) + } + + @Test + fun `postCourseScrap 성공 시 Success 상태로 갱신된다`() = runTest(testDispatcher) { + val postScrap = PostScrap(publicCourseId = 10L, scrapCount = 3L, scrapTF = true) + coEvery { + courseRepository.postCourseScrap(RequestPostCourseScrap(publicCourseId = 10, scrapTF = "true")) + } returns flow { + delay(1) + emit(Result.success(postScrap)) + } + + val states = mutableListOf>() + viewModel.courseScrapState.observeForever { states.add(it) } + + viewModel.postCourseScrap(id = 10, scrapTF = true) + advanceUntilIdle() + + assertEquals(listOf(UiStateV2.Loading, UiStateV2.Success(postScrap)), states) + } + + @Test + fun `modifyItemsToDelete는 같은 id를 다시 호출하면 선택을 해제한다`() = runTest(testDispatcher) { + viewModel.modifyItemsToDelete(1) + viewModel.modifyItemsToDelete(2) + assertEquals(listOf(1, 2), viewModel.itemsToDeleteLiveData.value) + + viewModel.modifyItemsToDelete(1) + assertEquals(listOf(2), viewModel.itemsToDeleteLiveData.value) + } + + @Test + fun `clearItemsToDelete는 선택 목록을 비운다`() = runTest(testDispatcher) { + viewModel.modifyItemsToDelete(1) + + viewModel.clearItemsToDelete() + + assertEquals(emptyList(), viewModel.itemsToDeleteLiveData.value) + } + + @Test + fun `saveClickedCourseId는 clickedCourseId를 갱신한다`() = runTest(testDispatcher) { + viewModel.saveClickedCourseId(7) + + assertEquals(7, viewModel.clickedCourseId) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc1de1294..057211dc5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -88,6 +88,7 @@ androidx-junit = "1.3.0" espresso = "3.7.0" mockk = "1.13.13" turbine = "1.2.0" +androidx-core-testing = "2.2.0" [libraries] # AndroidX @@ -112,6 +113,7 @@ compose-ui = { group = "androidx.compose.ui", name = "ui" } compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } @@ -185,6 +187,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +androidx-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "androidx-core-testing" } [bundles] firebase = [ @@ -208,6 +211,7 @@ compose = [ "compose-ui-graphics", "compose-ui-tooling-preview", "compose-material3", + "compose-runtime-livedata", ] compose-debug = [ "compose-ui-tooling",