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",