From 792d7cbad9e2e3faebc400eef7c60124eb6e88fa Mon Sep 17 00:00:00 2001 From: Unam Kim Date: Fri, 26 Jun 2026 22:22:25 +0900 Subject: [PATCH] =?UTF-8?q?StorageScrapFragment=20Compose=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B0=8F=20Vie?= =?UTF-8?q?wModel=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#3?= =?UTF-8?q?92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: StorageScrapFragment Compose 마이그레이션 및 ViewModel 테스트 추가 XML/RecyclerView+DataBinding 기반 StorageScrapFragment를 Jetpack Compose로 포팅. 동작은 그대로 유지하고 구현 방식만 교체. StorageViewModel unit test 10개(MockK+Turbine+coroutines-test)도 함께 추가해 회귀 안전망을 마련. * ci: PR CI에 unit test 실행 단계 추가 및 트리거 확대 기존엔 assembleDebug만 돌고 테스트 검증 없이 빌드 체크만 했음. testDebugUnitTest 스텝을 추가해 모든 PR에서 테스트 결과가 GitHub Actions 체크로 객관적으로 남도록 하고, 트리거도 develop 베이스가 아닌 PR에도 적용되도록 확대. * fix: CI JDK 버전을 21로 맞춤 (gradle-daemon-jvm.properties와 일치) gradle/gradle-daemon-jvm.properties는 JetBrains JDK 21을 요구하는데 CI는 Temurin 17로 고정돼 있어 불일치. 이 차이가 원인으로 보이는 kapt+DataBinding NullPointerException(ProcessDataBinding.getSupportedOptions, processingEnv null)이 CI에서만 재현됐음. JDK 버전을 21로 맞춰 검증. * fix: CodeRabbit이 발견한 Compose 포팅 회귀 2건 수정 - 빈 화면이 초기 로딩 중에도 잠깐 보이던 문제: 원본 XML은 로딩 중 그리드/빈화면 둘 다 숨겼는데 Compose 버전은 courses 기본값이 빈 리스트라 로딩 중에도 EmptyScrapView가 떴음. isLoading 조건 추가로 수정. - 동일한 에러가 연속으로 발생하면 두 번째 스낵바가 안 뜨던 문제: LiveData는 같은 값이어도 항상 옵저버를 호출하지만 Compose State는 동일 키면 LaunchedEffect가 재실행되지 않아서 생긴 차이. 에러를 보여준 뒤 즉시 errorMessage를 null로 리셋해서 1회성 이벤트로 만듦. * test: StorageScrapScreen Compose UI test 3건 추가 검증 매트릭스의 ⚠️ 미검증 항목(카드탭 이동, 빈 화면 진입) 두 곳을 ComposeTestRule로 채움. 빈 화면 테스트는 오늘 고친 로딩 중 깜빡임 회귀를 그대로 락인. * ci: Compose UI test(androidTest)를 에뮬레이터로 자동 실행하는 잡 추가 connectedDebugAndroidTest는 기기/에뮬레이터가 필요해 기존 unit test 스텝으로 커버되지 않았음. reactivecircus/android-emulator-runner로 PR마다 자동 실행되도록 별도 잡(android-test)을 추가하고, 결과 XML을 아티팩트로 업로드. REMOTE_KEY_FORCE_UPDATE secret 누락 이슈가 풀려야 함께 정상 동작함. * fix: 스크랩 해제(하트탭) 시 누락된 로딩 피드백 복구 원본은 courseScrapState Loading 상태에서 프로그레스바를 띄웠지만 Compose 포팅 시 PullToRefreshBox의 isRefreshing이 myScrapCourseGetState에만 연결돼 하트탭 네트워크 대기 중 아무 피드백 없이 멈춰있다가 아이템이 사라지는 회귀가 있었음. before/after 영상 재검토로 발견. * refactor: 로딩 상태 결합 로직을 순수 함수로 분리 + 회귀 방지 테스트 추가 StorageScrapUiState.from()으로 getState/scrapState의 Loading 결합 판단을 Fragment 밖으로 빼서 에뮬레이터 없이 JVM 유닛 테스트로 고정. StorageScrapScreen에는 isLoading=true일 때 로딩 인디케이터가 실제로 보이는지 검증하는 Compose UI test 추가 (네트워크 타이밍에 의존하지 않음). * fix: CI 에뮬레이터 잡을 macos-latest(Apple Silicon)에 맞춰 arm64-v8a로 수정 x86_64 시스템 이미지는 aarch64 호스트의 QEMU2에서 못 돌아서 에뮬레이터가 즉시 죽고 10분간 부팅 대기만 하다 타임아웃났음. CI 동작 자체가 아니라 잡 설정 문제였음. * fix: 안드로이드 에뮬레이터 CI 잡을 ubuntu-latest+KVM으로 전환 macOS 러너는 HVF가 HV_UNSUPPORTED를 내면서 하드웨어 가속 자체가 막혀 에뮬레이터가 못 떴음. android-emulator-runner 공식 문서가 권장하는 ubuntu-latest + KVM 활성화 방식으로 변경. * fix: ExampleInstrumentedTest의 하드코딩된 패키지명 오타 수정 com.example.runnect로 박혀있던 안드로이드 스튜디오 생성 샘플 테스트. connectedDebugAndroidTest가 CI에서 처음 실제로 도니까 드러난 기존 버그. * ci: 테스트별 성공/실패를 PR 체크에서 바로 확인하도록 test-reporter 추가 testLogging으로 유닛 테스트가 콘솔에 테스트명 단위 PASSED/FAILED를 찍게 하고, dorny/test-reporter로 유닛/Compose UI 테스트 결과를 각각 별도 체크(Unit Test Results / Compose UI Test Results)로 게시해서 어떤 테스트가 깨졌는지 PR에서 클릭 한 번으로 특정 가능하게 함. * fix: test-reporter 체크런 생성에 필요한 actions:read 권한 추가 dorny/test-reporter가 테스트 결과 요약은 만들었지만 체크런 생성 API 호출 직전에 조용히 멈췄음. actions:read 권한 누락이 원인. * fix: dorny/test-reporter를 v3로 올려 체크런 대신 잡 서머리에만 찍히던 문제 해결 v1은 Job Summary로만 동작해서 PR 체크 목록에 안 보였음. 공식 문서가 권장하는 v3로 올려서 실제 체크런으로 생성되게 함. * ci: dorny/test-reporter 대신 자체 스크립트로 테스트 결과를 PR 코멘트에 게시 dorny/test-reporter(v1/v3 둘 다)는 job summary는 만들지만 체크런 생성은 조용히 안 됐음(에러 없이 스킵, 원인 특정 못함). 더 기본적이고 통제 가능한 방식으로 전환: JUnit XML을 직접 파싱(.github/scripts/post_test_report.py) 해서 테스트별 ✅/❌ 마크다운을 PR 코멘트로 직접 게시(마커 기반으로 같은 코멘트를 갱신, 스팸 방지). * fix: 테스트 결과 코멘트에서 testcase의 classname으로 그루핑하도록 수정 connectedAndroidTest는 디바이스 1대당 XML 1개에 모든 클래스의 테스트를 합쳐서 쓰는데, testsuite 레벨 name은 첫 번째 클래스명만 가져서 나머지 클래스 테스트가 잘못 묶였음. * feat: 테스트 결과 코멘트의 각 테스트에 고정 앵커 추가 PR 본문 검증 매트릭스에서 특정 테스트의 최신 통과 여부로 바로 링크 걸 수 있게, classname+테스트명 해시로 만든 안정적인 anchor id를 각 줄에 부여. * ci: 테스트 실패 시에만 PR 코멘트 게시, 통과 시 코멘트 삭제 - 실패한 테스트만 출력하도록 post_test_report.py 단순화 - 전부 통과하면 기존 실패 코멘트를 삭제, 코멘트 없으면 무시 - 통과/실패 여부는 CI 체크 뱃지로 확인 가능하므로 성공 코멘트 불필요 --------- Co-authored-by: unam98 --- .github/scripts/post_test_report.py | 48 ++++ .github/workflows/CI.yml | 168 ++++++++++- app/build.gradle | 7 + .../runnect/ExampleInstrumentedTest.kt | 2 +- .../storage/StorageScrapScreenTest.kt | 109 +++++++ .../storage/StorageScrapFragment.kt | 269 ++++++------------ .../storage/StorageScrapScreen.kt | 232 +++++++++++++++ .../storage/adapter/StorageScrapAdapter.kt | 71 ----- .../callback/listener/OnHeartButtonClick.kt | 5 - .../callback/listener/OnScrapItemClick.kt | 7 - .../res/layout/fragment_storage_scrap.xml | 136 --------- .../main/res/layout/item_storage_scrap.xml | 86 ------ app/src/main/res/values/strings.xml | 2 + .../storage/StorageScrapUiStateTest.kt | 73 +++++ .../storage/StorageViewModelTest.kt | 222 +++++++++++++++ gradle/libs.versions.toml | 4 + 16 files changed, 952 insertions(+), 489 deletions(-) create mode 100644 .github/scripts/post_test_report.py create mode 100644 app/src/androidTest/java/com/runnect/runnect/presentation/storage/StorageScrapScreenTest.kt create mode 100644 app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt delete mode 100644 app/src/main/java/com/runnect/runnect/presentation/storage/adapter/StorageScrapAdapter.kt delete mode 100644 app/src/main/java/com/runnect/runnect/util/callback/listener/OnHeartButtonClick.kt delete mode 100644 app/src/main/java/com/runnect/runnect/util/callback/listener/OnScrapItemClick.kt delete mode 100644 app/src/main/res/layout/fragment_storage_scrap.xml delete mode 100644 app/src/main/res/layout/item_storage_scrap.xml create mode 100644 app/src/test/java/com/runnect/runnect/presentation/storage/StorageScrapUiStateTest.kt create mode 100644 app/src/test/java/com/runnect/runnect/presentation/storage/StorageViewModelTest.kt 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",