diff --git a/app/src/androidTest/java/com/yapp/plus/navigation/MyPageUiTest.kt b/app/src/androidTest/java/com/yapp/plus/navigation/MyPageUiTest.kt new file mode 100644 index 00000000..f937aca6 --- /dev/null +++ b/app/src/androidTest/java/com/yapp/plus/navigation/MyPageUiTest.kt @@ -0,0 +1,166 @@ +package com.yapp.plus.navigation + +import android.graphics.Bitmap +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.test.platform.app.InstrumentationRegistry +import com.yapp.plus.core.designsystem.theme.YappTheme +import com.yapp.plus.feature.main.my.MyAttendanceHistoryScreen +import com.yapp.plus.feature.main.my.MyPreviewData +import com.yapp.plus.feature.main.my.MyScreen +import com.yapp.plus.feature.main.my.model.MyAttendanceHistoryState +import com.yapp.plus.feature.main.my.model.MyProfileUiState +import com.yapp.plus.feature.main.my.model.MySessionSummary +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import java.io.File + +class MyPageUiTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun historyNavigationAndRulesWorkWithoutInlineExpansion() { + var history by mutableStateOf(false) + var myState by mutableStateOf(MyPreviewData.state) + composeRule.setContent { + YappTheme(darkTheme = false, dynamicColor = false) { + Box( + Modifier + .requiredSize( + 393.dp, + 756.dp, + ).background(Color.White) + .testTag("my-screen"), + ) { + if (history) { + MyAttendanceHistoryScreen( + state = MyPreviewData.history, + onBack = { history = false }, + onRetry = {}, + onLoadMore = {}, + ) + } else { + MyScreen( + state = myState, + onAttendanceHistoryClick = { history = true }, + onContactClick = {}, + ) + } + } + } + } + saveScreenshot("my-summary") + composeRule.onNodeWithContentDescription("출석 규칙").performClick() + composeRule.onNodeWithText("1시간 지각/조퇴").assertIsDisplayed() + composeRule.onNodeWithText("-20점").assertIsDisplayed() + saveWindowScreenshot("my-rules") + composeRule.onNodeWithText("확인").performClick() + composeRule.onNodeWithText("출석 내역").performClick() + composeRule.onNodeWithText("세션 출석 내역").assertIsDisplayed() + composeRule.onNodeWithText("개발-디자인 세션").assertIsDisplayed() + composeRule.onNodeWithText("마이페이지").assertDoesNotExist() + saveScreenshot("my-history") + composeRule.onNodeWithContentDescription("마이페이지로 돌아가기").performClick() + composeRule.onNodeWithText("내 출석 현황").assertIsDisplayed() + composeRule.runOnIdle { + myState = + MyPreviewData.state.copy( + profile = MyProfileUiState.Unavailable, + score = MyPreviewData.score.copy(lateExemptionCount = null), + ) + } + composeRule.onNodeWithText("김야뿌").assertDoesNotExist() + composeRule.onNodeWithText("지각 면제권").assertIsDisplayed() + composeRule.onNodeWithText("이용문의").assertIsDisplayed() + saveScreenshot("my-unconnected") + } + + @Test + fun emptyHistoryShowsFigmaGraphicAndServerCounts() { + composeRule.setContent { + YappTheme(darkTheme = false, dynamicColor = false) { + Box( + Modifier + .requiredSize( + 393.dp, + 756.dp, + ).background(Color.White) + .testTag("my-screen"), + ) { + MyAttendanceHistoryScreen( + state = + MyAttendanceHistoryState.Content( + MySessionSummary(12, 12, 0), + emptyList(), + ), + onBack = {}, + onRetry = {}, + onLoadMore = {}, + ) + } + } + } + composeRule.onNodeWithText("아직 출석 내역이 없어요").assertIsDisplayed() + composeRule.onNodeWithText("진행률").assertIsDisplayed() + saveScreenshot("my-history-empty") + } + + @Test + fun loadingFailureRetriesWithoutInventingAttendance() { + var retries = 0 + composeRule.setContent { + YappTheme { + MyAttendanceHistoryScreen( + state = MyAttendanceHistoryState.Error, + onBack = {}, + onRetry = { retries++ }, + onLoadMore = {}, + ) + } + } + composeRule.onNodeWithText("아직 출석 내역이 없어요").assertDoesNotExist() + composeRule.onNodeWithText("다시 시도").performClick() + composeRule.runOnIdle { assertEquals(1, retries) } + } + + private fun saveScreenshot(name: String) { + val bitmap = composeRule.onNodeWithTag("my-screen").captureToImage().asAndroidBitmap() + saveBitmap(bitmap, name) + } + + private fun saveWindowScreenshot(name: String) { + val bitmap = + checkNotNull(InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()) + saveBitmap(bitmap, name) + } + + private fun saveBitmap( + bitmap: Bitmap, + name: String, + ) { + val directory = + File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "my-figma") + directory.mkdirs() + File(directory, "$name.png").outputStream().use { + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) + } + } +} diff --git a/app/src/androidTest/java/com/yapp/plus/navigation/SessionOperationNavigationUiTest.kt b/app/src/androidTest/java/com/yapp/plus/navigation/SessionOperationNavigationUiTest.kt index d6da299f..2cc27136 100644 --- a/app/src/androidTest/java/com/yapp/plus/navigation/SessionOperationNavigationUiTest.kt +++ b/app/src/androidTest/java/com/yapp/plus/navigation/SessionOperationNavigationUiTest.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.junit4.StateRestorationTester import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText @@ -16,6 +17,7 @@ import androidx.compose.ui.test.performClick import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController +import androidx.test.espresso.Espresso import androidx.test.ext.junit.runners.AndroidJUnit4 import com.yapp.plus.core.designsystem.theme.YappTheme import com.yapp.plus.testing.NavigationTestActivity @@ -41,6 +43,25 @@ class SessionOperationNavigationUiTest { assertSessionOperationReturnsToLogin(isLogout = false) } + @Test + fun historyRestoresWithoutTabsAndSystemBackReturnsToMy() { + val restoration = StateRestorationTester(composeRule) + restoration.setContent { + YappTheme { + MainTabScaffold(initialTab = MainTab.My) + } + } + composeRule.onNodeWithText("MY").assertIsDisplayed() + composeRule.onNodeWithText("출석 내역").performClick() + composeRule.onNodeWithText("MY").assertDoesNotExist() + restoration.emulateSavedInstanceStateRestore() + composeRule.onNodeWithContentDescription("마이페이지로 돌아가기").assertIsDisplayed() + composeRule.onNodeWithText("MY").assertDoesNotExist() + Espresso.pressBack() + composeRule.onNodeWithText("마이페이지").assertIsDisplayed() + composeRule.onNodeWithText("MY").assertIsDisplayed() + } + private fun assertSessionOperationReturnsToLogin(isLogout: Boolean) { var hasSession by mutableStateOf(true) var logoutRequests = 0 diff --git a/app/src/main/java/com/yapp/plus/navigation/MainTabScaffold.kt b/app/src/main/java/com/yapp/plus/navigation/MainTabScaffold.kt index 312c7987..53acf2ee 100644 --- a/app/src/main/java/com/yapp/plus/navigation/MainTabScaffold.kt +++ b/app/src/main/java/com/yapp/plus/navigation/MainTabScaffold.kt @@ -36,6 +36,7 @@ internal fun MainTabScaffold( ) { var selectedTab by rememberSaveable(initialTab) { mutableStateOf(initialTab) } var isSettingsVisible by rememberSaveable { mutableStateOf(false) } + var isMyHistoryVisible by rememberSaveable { mutableStateOf(false) } var selectedNoticeId by rememberSaveable { mutableStateOf(null) } BackHandler(enabled = selectedTab == MainTab.Board && selectedNoticeId != null) { selectedNoticeId = null @@ -43,17 +44,21 @@ internal fun MainTabScaffold( BackHandler(enabled = selectedTab == MainTab.My && isSettingsVisible) { isSettingsVisible = false } + BackHandler(enabled = selectedTab == MainTab.My && isMyHistoryVisible) { + isMyHistoryVisible = false + } Scaffold( modifier = modifier.fillMaxSize(), containerColor = YappMainColor.surface, contentWindowInsets = WindowInsets(0, 0, 0, 0), bottomBar = { - if (!isSettingsVisible && selectedNoticeId == null) { + if (!isSettingsVisible && !isMyHistoryVisible && selectedNoticeId == null) { MainTabNavigation( selectedTab = selectedTab, onTabSelected = { selectedTab = it isSettingsVisible = false + isMyHistoryVisible = false selectedNoticeId = null }, ) @@ -98,7 +103,12 @@ internal fun MainTabScaffold( onDeleteAccountErrorDismissed = onDeleteAccountErrorDismissed, ) } else { - MyRoute(onSettingsClick = { isSettingsVisible = true }) + MyRoute( + onSettingsClick = { isSettingsVisible = true }, + onAttendanceHistoryClick = { isMyHistoryVisible = true }, + onBack = { isMyHistoryVisible = false }, + isHistoryVisible = isMyHistoryVisible, + ) } } } diff --git a/docs/wiki/product-policy.md b/docs/wiki/product-policy.md index b622184d..1e0f2da6 100644 --- a/docs/wiki/product-policy.md +++ b/docs/wiki/product-policy.md @@ -134,9 +134,9 @@ Notion 표에는 출석·지각·결석의 시간 구간이 제시되어 있지 - 사용자가 [Figma](https://www.figma.com/design/LUFGoDnVmwJT43QhecBI8x/YAPP-?node-id=222-40302)를 My와 설정의 UI/UX 기준으로 지정했습니다. 설정의 앱 버전, 탈퇴 문의 이메일·메뉴와 확장 안내를 제거합니다. - 탈퇴 확인은 “정말 탈퇴하시겠어요?”, “탈퇴하시면 모든 정보가 삭제돼요.”, “취소 / 탈퇴하기”를 사용합니다. 서버의 비식별화·이력 보존과 다른 문구임을 설명한 뒤 사용자가 Figma를 따르도록 재확인했습니다. 서버 계약을 바꾸는 결정은 아닙니다. - 미지원 API는 사용자 요청대로 UI를 유지하고 연결만 비웁니다. 설정의 알림 토글은 Figma 초기 상태에서 화면 내 변경·복원만 제공하며 실제 푸시 설정 저장 성공으로 취급하지 않습니다. -- 프로필 일러스트·회원 배지·기수·직군 컴포넌트와 debug Preview를 준비했습니다. 미확인 값은 `—`로 표시합니다. 메인은 프로필·출석 요약·규칙·메뉴를 조합하고 별도 출석 내역 화면은 상태·재시도·추가 조회 콜백을 받습니다. ViewModel과 App 이동 연결은 후속입니다. +- 프로필 일러스트·회원 배지·기수·직군 컴포넌트와 debug Preview를 준비했습니다. 미확인 값은 `—`로 표시합니다. 메인은 프로필·출석 요약·규칙·메뉴를 조합하고 별도 출석 내역 화면은 상태·재시도·추가 조회 콜백을 받습니다. ViewModel이 인증 응답에 저장한 이름과 서버 출석 점수·커서 이력을 조회하고 App이 출석 내역 진입·뒤로가기·복원을 관리합니다. - 출석 요약 카드·서버 상태별 배지·규칙 팝업과 표시 모델·개별 Preview를 준비했습니다. 값이 없는 요약은 `—`로 표시하고 면제권을 조퇴 횟수로 대체하지 않습니다. 규칙 문구는 Figma 원문을 표시하며 클라이언트 판정 로직으로 추가하지 않습니다. -- 적용 상태: 설정과 My 메인·별도 출석 내역 UI를 반영했습니다. 실제 조회·화면 이동 연결은 후속 PR입니다. 근거는 [이슈 #160](https://github.com/YAPP-admin/yapp-plus-android/issues/160)과 DEC-0043입니다. +- 적용 상태: 설정과 My 메인·별도 출석 내역 UI, 이름·점수·이력 조회와 App 이동을 연결했습니다. 미확인 회원 정보·면제권·알림 설정·이용 문의 URL은 연결만 비웁니다. 근거는 [이슈 #160](https://github.com/YAPP-admin/yapp-plus-android/issues/160)과 DEC-0043입니다. ## 계정·기수 통합 탈퇴 diff --git a/feature/main/my/build.gradle.kts b/feature/main/my/build.gradle.kts index 5223b8f1..06713f24 100644 --- a/feature/main/my/build.gradle.kts +++ b/feature/main/my/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("yapp.android.compose-library") + id("yapp.hilt") } android { @@ -7,12 +8,20 @@ android { } dependencies { - implementation(project(":core:designsystem")) implementation(project(":domain")) + implementation(project(":core:designsystem")) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.hilt.android) + implementation(libs.kotlinx.coroutines.core) + ksp(libs.hilt.compiler) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) debugImplementation(libs.androidx.compose.ui.tooling) } diff --git a/feature/main/my/src/main/java/com/yapp/plus/feature/main/my/MyRoute.kt b/feature/main/my/src/main/java/com/yapp/plus/feature/main/my/MyRoute.kt index b3e79264..00ab4678 100644 --- a/feature/main/my/src/main/java/com/yapp/plus/feature/main/my/MyRoute.kt +++ b/feature/main/my/src/main/java/com/yapp/plus/feature/main/my/MyRoute.kt @@ -1,15 +1,46 @@ package com.yapp.plus.feature.main.my import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.yapp.plus.feature.main.my.model.MyAttendanceScoreState @Composable fun MyRoute( onSettingsClick: () -> Unit, + onAttendanceHistoryClick: () -> Unit, + onBack: () -> Unit, + isHistoryVisible: Boolean, modifier: Modifier = Modifier, + onContactClick: () -> Unit = {}, ) { - MyScreen( - onSettingsClick = onSettingsClick, - modifier = modifier, - ) + val viewModel: MyViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(isHistoryVisible) { + viewModel.fetchProfile() + viewModel.fetchScore() + if (isHistoryVisible) viewModel.fetchHistory() + } + if (isHistoryVisible) { + MyAttendanceHistoryScreen( + state = state.attendanceHistory, + absentCount = (state.score as? MyAttendanceScoreState.Content)?.score?.absentCount, + onBack = onBack, + onRetry = viewModel::fetchHistory, + onLoadMore = viewModel::fetchNextPage, + modifier = modifier, + ) + } else { + MyScreen( + state = state, + onRetryScore = viewModel::fetchScore, + onAttendanceHistoryClick = onAttendanceHistoryClick, + onSettingsClick = onSettingsClick, + onContactClick = onContactClick, + modifier = modifier, + ) + } } diff --git a/feature/main/my/src/main/java/com/yapp/plus/feature/main/my/MyViewModel.kt b/feature/main/my/src/main/java/com/yapp/plus/feature/main/my/MyViewModel.kt new file mode 100644 index 00000000..9ad0edd4 --- /dev/null +++ b/feature/main/my/src/main/java/com/yapp/plus/feature/main/my/MyViewModel.kt @@ -0,0 +1,127 @@ +package com.yapp.plus.feature.main.my + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.yapp.plus.domain.attendance.AttendanceReadRepository +import com.yapp.plus.domain.auth.UserProfileRepository +import com.yapp.plus.feature.main.my.model.MyAttendanceHistoryState +import com.yapp.plus.feature.main.my.model.MyAttendanceScoreState +import com.yapp.plus.feature.main.my.model.MyProfileUiModel +import com.yapp.plus.feature.main.my.model.MyProfileUiState +import com.yapp.plus.feature.main.my.model.MyUiState +import com.yapp.plus.feature.main.my.model.toUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class MyViewModel + @Inject + constructor( + private val repository: AttendanceReadRepository, + private val profileRepository: UserProfileRepository, + ) : ViewModel() { + private val mutableState = MutableStateFlow(MyUiState()) + val uiState = mutableState.asStateFlow() + private var scoreJob: Job? = null + private var historyJob: Job? = null + private val loadedCursors = mutableSetOf() + + fun fetchProfile() { + viewModelScope.launch { + val name = + try { + profileRepository.fetchName() + } catch (exception: CancellationException) { + throw exception + } catch (_: Exception) { + null + } + mutableState.update { + it.copy( + profile = + name?.let { MyProfileUiState.Content(MyProfileUiModel(it)) } + ?: MyProfileUiState.Unavailable, + ) + } + } + } + + fun fetchScore() { + if (scoreJob?.isActive == true) return + scoreJob = + viewModelScope.launch { + mutableState.update { it.copy(score = MyAttendanceScoreState.Loading) } + try { + val score = repository.fetchScore() + mutableState.update { + it.copy( + score = + score?.let { value -> MyAttendanceScoreState.Content(value) } + ?: MyAttendanceScoreState.Ineligible, + ) + } + } catch (exception: CancellationException) { + throw exception + } catch (_: Exception) { + mutableState.update { it.copy(score = MyAttendanceScoreState.Error) } + } + } + } + + fun fetchHistory() { + if (historyJob?.isActive == true) return + historyJob = + viewModelScope.launch { + updateHistory(MyAttendanceHistoryState.Loading) + try { + val page = repository.fetchHistory().toUiState() + loadedCursors.clear() + updateHistory(page) + } catch (exception: CancellationException) { + throw exception + } catch (_: Exception) { + updateHistory(MyAttendanceHistoryState.Error) + } + } + } + + fun fetchNextPage() { + val current = + mutableState.value.attendanceHistory as? MyAttendanceHistoryState.Content ?: return + val cursor = current.nextCursor ?: return + if (historyJob?.isActive == true) return + historyJob = + viewModelScope.launch { + updateHistory(current.copy(isLoadingMore = true, hasLoadMoreError = false)) + try { + val page = repository.fetchHistory(cursor).toUiState() + check(page.nextCursor != cursor && page.nextCursor !in loadedCursors) + loadedCursors.add(cursor) + updateHistory( + page.copy( + items = + (current.items + page.items) + .associateBy { it.sessionId } + .values + .toList(), + ), + ) + } catch (exception: CancellationException) { + updateHistory(current) + throw exception + } catch (_: Exception) { + updateHistory(current.copy(hasLoadMoreError = true)) + } + } + } + + private fun updateHistory(history: MyAttendanceHistoryState) { + mutableState.update { it.copy(attendanceHistory = history) } + } + } diff --git a/feature/main/my/src/main/java/com/yapp/plus/feature/main/my/model/MyAttendanceHistoryMapper.kt b/feature/main/my/src/main/java/com/yapp/plus/feature/main/my/model/MyAttendanceHistoryMapper.kt new file mode 100644 index 00000000..97fbe3ed --- /dev/null +++ b/feature/main/my/src/main/java/com/yapp/plus/feature/main/my/model/MyAttendanceHistoryMapper.kt @@ -0,0 +1,40 @@ +package com.yapp.plus.feature.main.my.model + +import com.yapp.plus.domain.attendance.AttendanceHistoryEntry +import com.yapp.plus.domain.attendance.AttendanceHistoryPage +import com.yapp.plus.domain.attendance.AttendanceResultStatus +import com.yapp.plus.domain.attendance.AttendanceStaffDecision + +internal fun AttendanceHistoryPage.toUiState(): MyAttendanceHistoryState.Content = + MyAttendanceHistoryState.Content( + summary = + MySessionSummary( + totalSessionCount = totalSessionCount, + remainingSessionCount = totalSessionCount - startedSessionCount, + ), + items = + items.map { + MyAttendanceHistoryItem(it.sessionId, it.sessionName, it.toUiStatus()) + }, + nextCursor = nextCursor, + ) + +private fun AttendanceHistoryEntry.toUiStatus(): MyAttendanceStatus = + when (status) { + AttendanceResultStatus.PRESENT -> MyAttendanceStatus.Present + AttendanceResultStatus.LATE -> MyAttendanceStatus.Late + AttendanceResultStatus.ABSENT -> MyAttendanceStatus.Absent + AttendanceResultStatus.EARLY_LEAVE -> MyAttendanceStatus.EarlyLeave + AttendanceResultStatus.LATE_AND_EARLY_LEAVE -> MyAttendanceStatus.LateAndEarlyLeave + AttendanceResultStatus.EXCUSED_ABSENCE -> MyAttendanceStatus.ExcusedAbsence + AttendanceResultStatus.UNDECIDED -> staffDecision.toPendingUiStatus() + } + +private fun AttendanceStaffDecision?.toPendingUiStatus(): MyAttendanceStatus = + when (this) { + AttendanceStaffDecision.PENDING -> MyAttendanceStatus.Pending + AttendanceStaffDecision.REJECTED -> MyAttendanceStatus.Rejected + AttendanceStaffDecision.NOT_SUBMITTED -> MyAttendanceStatus.NotSubmitted + AttendanceStaffDecision.NOT_SELECTED -> MyAttendanceStatus.NotSelected + else -> MyAttendanceStatus.Undecided + } diff --git a/feature/main/my/src/test/java/com/yapp/plus/feature/main/my/FakeAttendanceReadRepository.kt b/feature/main/my/src/test/java/com/yapp/plus/feature/main/my/FakeAttendanceReadRepository.kt new file mode 100644 index 00000000..2ff8ea4e --- /dev/null +++ b/feature/main/my/src/test/java/com/yapp/plus/feature/main/my/FakeAttendanceReadRepository.kt @@ -0,0 +1,30 @@ +package com.yapp.plus.feature.main.my + +import com.yapp.plus.domain.attendance.AttendanceHistoryPage +import com.yapp.plus.domain.attendance.AttendanceReadRepository +import com.yapp.plus.domain.attendance.AttendanceScore +import kotlinx.coroutines.CompletableDeferred + +internal class FakeAttendanceReadRepository : AttendanceReadRepository { + var score: AttendanceScore? = AttendanceScore(1, 86, 3, 1, 0, 1) + var scoreError: Exception? = null + var historyError: Exception? = null + var gate: CompletableDeferred? = null + var scoreRequests = 0 + val historyCursors = mutableListOf() + val pages = mutableMapOf() + + override suspend fun fetchScore(): AttendanceScore? { + scoreRequests++ + gate?.await() + scoreError?.let { throw it } + return score + } + + override suspend fun fetchHistory(cursor: String?): AttendanceHistoryPage { + historyCursors.add(cursor) + gate?.await() + historyError?.let { throw it } + return pages[cursor] ?: AttendanceHistoryPage(0, 0, emptyList(), null) + } +} diff --git a/feature/main/my/src/test/java/com/yapp/plus/feature/main/my/MyViewModelTest.kt b/feature/main/my/src/test/java/com/yapp/plus/feature/main/my/MyViewModelTest.kt new file mode 100644 index 00000000..f959f0b6 --- /dev/null +++ b/feature/main/my/src/test/java/com/yapp/plus/feature/main/my/MyViewModelTest.kt @@ -0,0 +1,247 @@ +package com.yapp.plus.feature.main.my + +import androidx.lifecycle.viewModelScope +import com.yapp.plus.domain.attendance.AttendanceHistoryEntry +import com.yapp.plus.domain.attendance.AttendanceHistoryPage +import com.yapp.plus.domain.attendance.AttendanceResultStatus +import com.yapp.plus.domain.attendance.AttendanceStaffDecision +import com.yapp.plus.domain.auth.UserProfileRepository +import com.yapp.plus.feature.main.my.model.MyAttendanceHistoryState +import com.yapp.plus.feature.main.my.model.MyAttendanceScoreState +import com.yapp.plus.feature.main.my.model.MyAttendanceStatus +import com.yapp.plus.feature.main.my.model.MyProfileUiState +import com.yapp.plus.feature.main.my.model.toUiState +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MyViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val repository = FakeAttendanceReadRepository() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun profileUsesSavedNameAndMissingMembershipFieldsStayEmpty() = + runTest(dispatcher) { + val model = MyViewModel(repository, UserProfileRepository { "서버 이름" }) + model.fetchProfile() + advanceUntilIdle() + val profile = model.uiState.value.profile as MyProfileUiState.Content + assertEquals("서버 이름", profile.profile.name) + assertEquals(null, profile.profile.generation) + assertEquals(null, profile.profile.role) + } + + @Test + fun scoreFailureCanRetryAndDoesNotPreventHistoryLoading() = + runTest(dispatcher) { + repository.scoreError = IllegalStateException() + val model = + MyViewModel( + repository, + UserProfileRepository { null }, + ) + model.fetchScore() + model.fetchHistory() + advanceUntilIdle() + assertEquals(MyAttendanceScoreState.Error, model.uiState.value.score) + assertTrue(model.uiState.value.attendanceHistory is MyAttendanceHistoryState.Content) + repository.scoreError = null + model.fetchScore() + advanceUntilIdle() + assertEquals( + MyAttendanceScoreState.Content(repository.score!!), + model.uiState.value.score, + ) + assertEquals(2, repository.scoreRequests) + } + + @Test + fun ineligibleScoreDoesNotBecomeZeroPoints() = + runTest(dispatcher) { + repository.score = null + val model = + MyViewModel( + repository, + UserProfileRepository { null }, + ) + model.fetchScore() + advanceUntilIdle() + assertEquals(MyAttendanceScoreState.Ineligible, model.uiState.value.score) + } + + @Test + fun concurrentRepeatedRequestsAreCoalescedPerResource() = + runTest(dispatcher) { + repository.gate = CompletableDeferred() + val model = + MyViewModel( + repository, + UserProfileRepository { null }, + ) + repeat(3) { + model.fetchScore() + model.fetchHistory() + } + runCurrent() + assertEquals(1, repository.scoreRequests) + assertEquals(listOf(null), repository.historyCursors) + repository.gate!!.complete(Unit) + advanceUntilIdle() + } + + @Test + fun paginationFailureKeepsRecordsAndRetriesSameCursor() = + runTest(dispatcher) { + repository.pages[null] = page(listOf(3, 2), "next") + repository.pages["next"] = page(listOf(2, 1), null) + val model = + MyViewModel( + repository, + UserProfileRepository { null }, + ) + model.fetchHistory() + advanceUntilIdle() + repository.historyError = IllegalStateException() + model.fetchNextPage() + advanceUntilIdle() + assertTrue(history(model).hasLoadMoreError) + assertEquals(listOf(3L, 2L), history(model).items.map { it.sessionId }) + repository.historyError = null + model.fetchNextPage() + advanceUntilIdle() + assertEquals(listOf(3L, 2L, 1L), history(model).items.map { it.sessionId }) + assertFalse(history(model).hasLoadMoreError) + model.fetchNextPage() + assertEquals(listOf(null, "next", "next"), repository.historyCursors) + } + + @Test + fun refreshRestartsAtFirstPageAndDropsStaleRecords() = + runTest(dispatcher) { + repository.pages[null] = page(listOf(3), "next") + repository.pages["next"] = page(listOf(2), null) + val model = + MyViewModel( + repository, + UserProfileRepository { null }, + ) + model.fetchHistory() + advanceUntilIdle() + model.fetchNextPage() + advanceUntilIdle() + repository.pages[null] = page(listOf(4), null) + model.fetchHistory() + advanceUntilIdle() + assertEquals(listOf(4L), history(model).items.map { it.sessionId }) + assertEquals(listOf(null, "next", null), repository.historyCursors) + } + + @Test + fun cyclicCursorStopsAutomaticPaging() = + runTest(dispatcher) { + repository.pages[null] = page(listOf(3), "next") + repository.pages["next"] = page(listOf(2), "next") + val model = + MyViewModel( + repository, + UserProfileRepository { null }, + ) + model.fetchHistory() + advanceUntilIdle() + model.fetchNextPage() + advanceUntilIdle() + assertTrue(history(model).hasLoadMoreError) + assertEquals(listOf(3L), history(model).items.map { it.sessionId }) + } + + @Test + fun cancelledPagingPreservesExistingContent() = + runTest(dispatcher) { + repository.pages[null] = page(listOf(3), "next") + val model = + MyViewModel( + repository, + UserProfileRepository { null }, + ) + model.fetchHistory() + advanceUntilIdle() + repository.gate = CompletableDeferred() + model.fetchNextPage() + runCurrent() + model.viewModelScope.cancel() + advanceUntilIdle() + assertFalse(history(model).isLoadingMore) + assertFalse(history(model).hasLoadMoreError) + assertEquals(listOf(3L), history(model).items.map { it.sessionId }) + } + + @Test + fun pendingReviewAndManualPresentAreNotRenderedAsAbsence() { + val entries = + listOf( + AttendanceHistoryEntry( + 1, + "검토", + AttendanceResultStatus.UNDECIDED, + AttendanceStaffDecision.PENDING, + ), + AttendanceHistoryEntry( + 2, + "정정", + AttendanceResultStatus.PRESENT, + AttendanceStaffDecision.REJECTED, + ), + AttendanceHistoryEntry(3, "복합", AttendanceResultStatus.LATE_AND_EARLY_LEAVE, null), + ) + val state = AttendanceHistoryPage(12, 7, entries, null).toUiState() + assertEquals(5, state.summary.remainingSessionCount) + assertEquals( + listOf( + MyAttendanceStatus.Pending, + MyAttendanceStatus.Present, + MyAttendanceStatus.LateAndEarlyLeave, + ), + state.items.map { it.status }, + ) + } + + private fun history(model: MyViewModel): MyAttendanceHistoryState.Content = + model.uiState.value.attendanceHistory as MyAttendanceHistoryState.Content + + private fun page( + ids: List, + nextCursor: String?, + ) = AttendanceHistoryPage( + totalSessionCount = 12, + startedSessionCount = 6, + items = + ids.map { + AttendanceHistoryEntry(it.toLong(), "세션 $it", AttendanceResultStatus.PRESENT, null) + }, + nextCursor = nextCursor, + ) +}