diff --git a/app/src/androidTest/java/com/flint/android/presentation/onboarding/OnboardingTermsNavigationTest.kt b/app/src/androidTest/java/com/flint/android/presentation/onboarding/OnboardingTermsNavigationTest.kt new file mode 100644 index 00000000..3b618142 --- /dev/null +++ b/app/src/androidTest/java/com/flint/android/presentation/onboarding/OnboardingTermsNavigationTest.kt @@ -0,0 +1,188 @@ +package com.flint.android.presentation.onboarding + +import androidx.activity.ComponentActivity +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flint.android.core.common.util.UiState +import com.flint.android.core.designsystem.theme.FlintTheme +import com.flint.android.domain.model.terms.TermModel +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 약관 동의 상태가 화면 이동 후에도 유지되는지 실제 NavHost로 검증한다. + * + * QA 2차 1: 약관 동의 후 '자세히 보기' 진입 → 복귀 시 동의 상태 유지 + * QA 2차 2: 약관 동의 후 온보딩 진입 → 뒤로가기 복귀 시 동의 상태 유지 + * + * 프로덕션과 동일하게 상태를 화면 바깥(그래프 스코프)에 두고 실제로 이동/복귀시킨다. + * 수정 전에는 체크 상태가 OnboardingTermsScreen 내부 remember에 있어, + * 복귀 시 컴포지션이 새로 만들어지며 초기화됐다. + */ +@RunWith(AndroidJUnit4::class) +class OnboardingTermsNavigationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val service = + TermModel( + id = "1", + type = "SERVICE", + version = 1, + title = "서비스 이용약관", + content = "", + required = true, + activeAt = "2026-05-13T10:48:54.554Z", + ) + private val privacy = + TermModel( + id = "2", + type = "PRIVACY", + version = 1, + title = "개인정보 처리 방침", + content = "", + required = true, + activeAt = "2026-05-13T10:48:54.554Z", + ) + private val marketing = + TermModel( + id = "3", + type = "MARKETING", + version = 1, + title = "마케팅 정보 수신 동의", + content = "", + required = false, + activeAt = "2026-05-13T10:48:54.554Z", + ) + + private lateinit var navController: NavHostController + + private fun setContent() { + composeRule.setContent { + navController = rememberNavController() + + // 프로덕션에서 OnboardingViewModel이 nav graph에 스코프되어 하는 역할. + // NavHost 바깥에 두어 목적지 이동에도 살아남게 한다. + var termsState by remember { + mutableStateOf( + OnboardingTermsUiState( + termsState = UiState.Success(listOf(service, privacy, marketing)), + ), + ) + } + + FlintTheme { + NavHost(navController = navController, startDestination = "terms") { + composable("terms") { + OnboardingTermsScreen( + termsUiState = termsState, + onBackClick = {}, + onCheckClick = { termsState = termsState.toggleChecked(it) }, + onAllCheckClick = { termsState = termsState.toggleAllChecked() }, + onExpandClick = { termsState = termsState.toggleExpanded(it) }, + onAgreeClick = { navController.navigate("next") }, + onDetailClick = { navController.navigate("detail") }, + ) + } + composable("detail") { Text("약관 상세 화면") } + composable("next") { Text("온보딩 다음 단계") } + } + } + } + } + + private fun goBack() { + composeRule.runOnUiThread { navController.popBackStack() } + composeRule.waitForIdle() + } + + private fun checkRequiredTerms() { + composeRule.onNodeWithContentDescription("${service.title} 동의").performClick() + composeRule.onNodeWithContentDescription("${privacy.title} 동의").performClick() + } + + // ---- QA 2차 1 ---- + + @Test + fun `자세히 보기에서 복귀해도 동의 상태가 유지된다`() { + setContent() + + checkRequiredTerms() + composeRule.onNodeWithText("동의하기").assertIsEnabled() + + // 약관을 펼쳐 '자세히 보기'로 이동 + composeRule.onNodeWithContentDescription("${service.title} 펼치기").performClick() + composeRule.onNodeWithText("자세히 보기").performClick() + composeRule.onNodeWithText("약관 상세 화면").assertExists() + + goBack() + + // 체크와 펼침 상태가 그대로여야 한다 + composeRule.onNodeWithText("동의하기").assertIsEnabled() + composeRule.onNodeWithText("자세히 보기").assertExists() + } + + // ---- QA 2차 2 ---- + + @Test + fun `온보딩 다음 단계에서 뒤로가기로 복귀해도 동의 상태가 유지된다`() { + setContent() + + checkRequiredTerms() + composeRule.onNodeWithText("동의하기").assertIsEnabled() + + composeRule.onNodeWithText("동의하기").performClick() + composeRule.onNodeWithText("온보딩 다음 단계").assertExists() + + goBack() + + composeRule.onNodeWithText("동의하기").assertIsEnabled() + } + + // ---- 전체 동의 ---- + + @Test + fun `전체 동의 후 이동했다 복귀해도 전체 체크가 유지된다`() { + setContent() + + composeRule.onNodeWithText("전체 동의").performClick() + composeRule.onNodeWithText("동의하기").assertIsEnabled() + + composeRule.onNodeWithText("동의하기").performClick() + composeRule.onNodeWithText("온보딩 다음 단계").assertExists() + + goBack() + + composeRule.onNodeWithText("동의하기").assertIsEnabled() + // 선택 약관까지 체크된 상태가 유지되는지 확인 — 다시 누르면 전체 해제되어야 한다 + composeRule.onNodeWithText("전체 동의").performClick() + composeRule.onNodeWithText("동의하기").assertIsNotEnabled() + } + + // ---- 대조군 ---- + + @Test + fun `필수 약관을 체크하지 않으면 동의하기가 비활성화된다`() { + setContent() + + composeRule.onNodeWithText("동의하기").assertIsNotEnabled() + + composeRule.onNodeWithContentDescription("${marketing.title} 동의").performClick() + composeRule.onNodeWithText("동의하기").assertIsNotEnabled() + } +} diff --git a/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintBasicButton.kt b/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintBasicButton.kt index fa76afba..5ccc033d 100644 --- a/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintBasicButton.kt +++ b/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintBasicButton.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flint.android.R import com.flint.android.core.designsystem.interaction.FlintPressDefaults @@ -47,10 +48,14 @@ fun FlintBasicButton( contentPadding: PaddingValues, @DrawableRes leadingIconRes: Int? = null, pressedScale: Float = FlintPressDefaults.BUTTON_SCALE, + // 상태가 정한 테두리 굵기를 화면 단위로 덮어쓸 때만 지정한다. null이면 상태 기본값을 쓴다. + borderWidth: Dp? = null, ) { val background: Brush = state.background val contentColor: Color = state.contentColor - val border: BorderStroke? = state.border + val border: BorderStroke? = state.border?.let { stroke -> + if (borderWidth == null) stroke else BorderStroke(borderWidth, stroke.brush) + } val shape = RoundedCornerShape(8.dp) val interactionSource = remember { MutableInteractionSource() } diff --git a/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintMediumButton.kt b/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintMediumButton.kt index 87dcf22d..e03d63be 100644 --- a/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintMediumButton.kt +++ b/app/src/main/java/com/flint/android/core/designsystem/component/button/FlintMediumButton.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flint.android.core.designsystem.theme.FlintTheme @@ -24,6 +25,7 @@ fun FlintMediumButton( modifier: Modifier = Modifier, textStyle: TextStyle = if (state == FlintButtonState.Able) FlintTheme.typography.body1Sb16 else FlintTheme.typography.body1M16, enabled: Boolean = true, + borderWidth: Dp? = null, ) { FlintBasicButton( text = text, @@ -32,6 +34,7 @@ fun FlintMediumButton( textStyle = textStyle, enabled = enabled, contentPadding = PaddingValues(10.dp), + borderWidth = borderWidth, modifier = modifier .padding(vertical = 2.dp) diff --git a/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt b/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt index d91da312..82ef89d6 100644 --- a/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt @@ -150,7 +150,7 @@ private fun HomeScreen( SavedContentsSection( title = "최근 저장한 콘텐츠", - description = "현재 구독 중인 OTT에서 볼 수 있는 작품들이에요", + description = "관심이 가는 작품들을 다시 만나보세요", isAllVisible = false, onAllClick = {}, contentModelList = savedContentModelList, diff --git a/app/src/main/java/com/flint/android/presentation/main/MainNavigator.kt b/app/src/main/java/com/flint/android/presentation/main/MainNavigator.kt index b02306da..9b6e6211 100644 --- a/app/src/main/java/com/flint/android/presentation/main/MainNavigator.kt +++ b/app/src/main/java/com/flint/android/presentation/main/MainNavigator.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.NavOptions import androidx.navigation.compose.rememberNavController @@ -110,15 +109,10 @@ class MainNavigator( launchSingleTop = true } - fun navigateToLogin() { - navController.navigateToLogin( - navOptions { - popUpTo(navController.graph.findStartDestination().id) { - inclusive = true - } - launchSingleTop = true - }, - ) + // 시작 목적지(Splash)는 홈 진입 시 이미 스택에서 제거되므로 popUpTo(startDestination)으로는 + // 아무것도 정리되지 않는다. 로그인 화면에서는 앱으로 되돌아갈 수 없어야 하므로 스택을 전부 비운다. + fun navigateToLogin(navOptions: NavOptions? = clearStackNavOptions) { + navController.navigateToLogin(navOptions) } fun navigateToOnBoarding(tempToken: String, navOptions: NavOptions? = clearStackNavOptions) { diff --git a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingTermsScreen.kt b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingTermsScreen.kt index 60761a50..bd946e90 100644 --- a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingTermsScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingTermsScreen.kt @@ -22,9 +22,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -63,8 +60,11 @@ fun OnboardingTermsRoute( OnboardingTermsScreen( termsUiState = termsUiState, onBackClick = navigateUp, - onAgreeClick = { agreedIds -> - viewModel.agreeToTerms(agreedIds) + onCheckClick = viewModel::toggleTermChecked, + onAllCheckClick = viewModel::toggleAllTermsChecked, + onExpandClick = viewModel::toggleTermExpanded, + onAgreeClick = { + viewModel.agreeToTerms() navigateToOnboardingContent() }, onDetailClick = navigateToTermsDetail, @@ -76,20 +76,16 @@ fun OnboardingTermsRoute( fun OnboardingTermsScreen( termsUiState: OnboardingTermsUiState, onBackClick: () -> Unit, - onAgreeClick: (List) -> Unit, + onCheckClick: (termId: String) -> Unit, + onAllCheckClick: () -> Unit, + onExpandClick: (termId: String) -> Unit, + onAgreeClick: () -> Unit, onDetailClick: (termId: String) -> Unit = {}, modifier: Modifier = Modifier, ) { - val terms = (termsUiState.termsState as? UiState.Success)?.data ?: emptyList() - var checkedStates by remember(terms) { mutableStateOf(List(terms.size) { false }) } - var expandedStates by remember(terms) { mutableStateOf(List(terms.size) { false }) } - - // 필수 약관이 모두 체크되어야 버튼 활성화 - val requiredAllChecked = terms.isNotEmpty() && terms.indices - .filter { terms[it].required } - .all { checkedStates[it] } - - val allChecked = terms.isNotEmpty() && checkedStates.all { it } + val terms = termsUiState.terms + val requiredAllChecked = termsUiState.canProceed + val allChecked = termsUiState.isAllChecked Column( modifier = modifier @@ -113,10 +109,7 @@ fun OnboardingTermsScreen( Row( modifier = Modifier .fillMaxWidth() - .flintCardClickable { - val newState = !allChecked - checkedStates = List(terms.size) { newState } - } + .flintCardClickable(onClick = onAllCheckClick) .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -175,19 +168,13 @@ fun OnboardingTermsScreen( ) { Spacer(Modifier.height(16.dp)) - terms.forEachIndexed { index, term -> + terms.forEach { term -> TermRow( term = term, - isChecked = checkedStates[index], - isExpanded = expandedStates[index], - onCheckClick = { - checkedStates = checkedStates.toMutableList() - .also { it[index] = !it[index] } - }, - onExpandClick = { - expandedStates = expandedStates.toMutableList() - .also { it[index] = !it[index] } - }, + isChecked = termsUiState.isChecked(term.id), + isExpanded = termsUiState.isExpanded(term.id), + onCheckClick = { onCheckClick(term.id) }, + onExpandClick = { onExpandClick(term.id) }, onDetailClick = { onDetailClick(term.id) }, ) Spacer(Modifier.height(4.dp)) @@ -200,12 +187,7 @@ fun OnboardingTermsScreen( FlintLargeButton( text = "동의하기", state = if (requiredAllChecked) FlintButtonState.Able else FlintButtonState.Disable, - onClick = { - val agreedIds = terms.indices - .filter { checkedStates[it] } - .map { terms[it].id } - onAgreeClick(agreedIds) - }, + onClick = onAgreeClick, enabled = requiredAllChecked, modifier = Modifier .fillMaxWidth() @@ -236,7 +218,7 @@ private fun TermRow( imageVector = ImageVector.vectorResource( if (isChecked) R.drawable.ic_check_fill else R.drawable.ic_check_empty ), - contentDescription = null, + contentDescription = "${term.title} 동의", tint = Color.Unspecified, modifier = Modifier.flintIconClickable(onClick = onCheckClick), ) @@ -249,7 +231,7 @@ private fun TermRow( ) Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_down), - contentDescription = null, + contentDescription = "${term.title} 펼치기", tint = FlintTheme.colors.gray400, modifier = Modifier .rotate(if (isExpanded) 180f else 0f) @@ -328,6 +310,9 @@ private fun OnboardingTermsScreenPreview() { ) ), onBackClick = {}, + onCheckClick = {}, + onAllCheckClick = {}, + onExpandClick = {}, onAgreeClick = {}, ) } diff --git a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingUiState.kt b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingUiState.kt index 882a66e1..43aa8255 100644 --- a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingUiState.kt +++ b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingUiState.kt @@ -11,7 +11,49 @@ import kotlinx.collections.immutable.persistentListOf data class OnboardingTermsUiState( val termsState: UiState> = UiState.Empty, val agreedTermsIds: List = emptyList(), -) + // 체크/펼침 상태는 화면이 아니라 여기에 둔다. 화면 로컬 remember에 두면 + // '자세히 보기'나 다음 온보딩 단계로 이동할 때 컴포지션이 해제되며 초기화된다. + val checkedTermIds: Set = emptySet(), + val expandedTermIds: Set = emptySet(), +) { + val terms: List + get() = (termsState as? UiState.Success)?.data ?: emptyList() + + fun isChecked(termId: String): Boolean = termId in checkedTermIds + + fun isExpanded(termId: String): Boolean = termId in expandedTermIds + + val isAllChecked: Boolean + get() = terms.isNotEmpty() && terms.all { it.id in checkedTermIds } + + // 필수 약관이 모두 체크되어야 동의하기 버튼이 활성화된다 + val canProceed: Boolean + get() = terms.isNotEmpty() && terms.filter { it.required }.all { it.id in checkedTermIds } + + // 약관 목록 순서를 따라 체크된 것만 수집한다 + val agreedIds: List + get() = terms.filter { it.id in checkedTermIds }.map { it.id } + + fun toggleChecked(termId: String): OnboardingTermsUiState = copy( + checkedTermIds = if (termId in checkedTermIds) { + checkedTermIds - termId + } else { + checkedTermIds + termId + }, + ) + + fun toggleAllChecked(): OnboardingTermsUiState = copy( + checkedTermIds = if (isAllChecked) emptySet() else terms.map { it.id }.toSet(), + ) + + fun toggleExpanded(termId: String): OnboardingTermsUiState = copy( + expandedTermIds = if (termId in expandedTermIds) { + expandedTermIds - termId + } else { + expandedTermIds + termId + }, + ) +} enum class NicknameErrorType { DUPLICATE, // 이미 사용 중인 닉네임 diff --git a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt index 905b310b..5cc9dd27 100644 --- a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt @@ -91,8 +91,20 @@ class OnboardingViewModel } } - fun agreeToTerms(termIds: List) { - _termsUiState.update { it.copy(agreedTermsIds = termIds) } + fun toggleTermChecked(termId: String) { + _termsUiState.update { it.toggleChecked(termId) } + } + + fun toggleAllTermsChecked() { + _termsUiState.update { it.toggleAllChecked() } + } + + fun toggleTermExpanded(termId: String) { + _termsUiState.update { it.toggleExpanded(termId) } + } + + fun agreeToTerms() { + _termsUiState.update { it.copy(agreedTermsIds = it.agreedIds) } } // ---------- onboarding profile ---------- diff --git a/app/src/main/java/com/flint/android/presentation/setting/SettingScreen.kt b/app/src/main/java/com/flint/android/presentation/setting/SettingScreen.kt index f2de4691..26ccda1c 100644 --- a/app/src/main/java/com/flint/android/presentation/setting/SettingScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/setting/SettingScreen.kt @@ -231,6 +231,7 @@ private fun SettingProfileSection( state = FlintButtonState.ColorOutline, onClick = onEditProfileClick, textStyle = FlintTheme.typography.body2M14, + borderWidth = 1.dp, modifier = Modifier.weight(0.8f), ) } diff --git a/app/src/main/java/com/flint/android/presentation/setting/withdraw/WithdrawScreen.kt b/app/src/main/java/com/flint/android/presentation/setting/withdraw/WithdrawScreen.kt index 4d69b632..3c831074 100644 --- a/app/src/main/java/com/flint/android/presentation/setting/withdraw/WithdrawScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/setting/withdraw/WithdrawScreen.kt @@ -1,13 +1,13 @@ package com.flint.android.presentation.setting.withdraw import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding @@ -17,11 +17,15 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextIndent import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -138,21 +142,28 @@ private fun WithdrawNoticeList(modifier: Modifier = Modifier) { "관련 법령에 따라 일부 정보는 일정 기간 보관될 수 있으며, 해당 정보는 법적 의무 이외의 목적으로 사용되지 않습니다.", ) - Column(modifier = modifier) { + // 번호와 본문을 별도 Text로 나누면 숫자 글리프와 한글 글리프의 시각 중심이 달라 + // 미세하게 어긋나 보인다. 하나의 Text로 합치고 두 번째 줄부터 번호 너비만큼 + // 들여쓰기(hanging indent)하면 같은 줄 박스를 쓰므로 어긋날 여지가 없다. + val style = FlintTheme.typography.body1M16 + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { notices.forEachIndexed { index, text -> - Row { - Text( - text = "${index + 1}. ", - style = FlintTheme.typography.body1M16, - color = FlintTheme.colors.gray300, - ) - Text( - text = text, - style = FlintTheme.typography.body1M16, - color = FlintTheme.colors.gray300, - modifier = Modifier.offset(y = (-3).dp) - ) + val marker = "${index + 1}. " + val indent = remember(marker, style, density) { + with(density) { textMeasurer.measure(marker, style).size.width.toSp() } } + + Text( + text = marker + text, + style = style.copy(textIndent = TextIndent(restLine = indent)), + color = FlintTheme.colors.gray300, + ) } } } diff --git a/app/src/main/res/drawable/ic_kakao_full.png b/app/src/main/res/drawable/ic_kakao_full.png index 0c77c760..edab0649 100644 Binary files a/app/src/main/res/drawable/ic_kakao_full.png and b/app/src/main/res/drawable/ic_kakao_full.png differ diff --git a/app/src/test/java/com/flint/android/presentation/onboarding/TermsAgreementStateTest.kt b/app/src/test/java/com/flint/android/presentation/onboarding/TermsAgreementStateTest.kt new file mode 100644 index 00000000..6559057d --- /dev/null +++ b/app/src/test/java/com/flint/android/presentation/onboarding/TermsAgreementStateTest.kt @@ -0,0 +1,180 @@ +package com.flint.android.presentation.onboarding + +import com.flint.android.core.common.util.UiState +import com.flint.android.domain.model.terms.TermModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 약관 동의 상태 유지 테스트 + * + * QA 2차 1: 약관 동의 후 '자세히 보기' 진입 후 복귀 시 동의 상태가 유지되어야 한다 + * QA 2차 2: 약관 동의 후 온보딩 진입 → 뒤로가기로 복귀 시 동의 상태가 유지되어야 한다 + * + * 두 버그의 원인은 동일하다. 체크 상태가 OnboardingTermsScreen의 컴포저블 로컬 + * remember에 있어서, 화면을 벗어나면 컴포지션이 해제되며 상태가 초기화됐다. + * 상태를 OnboardingTermsUiState로 올려 nav graph에 스코프된 OnboardingViewModel이 + * 들고 있게 하고, 그 전이 로직을 여기서 검증한다. + */ +class TermsAgreementStateTest { + private val service = + TermModel( + id = "1", + type = "SERVICE", + version = 1, + title = "서비스 이용약관", + content = "", + required = true, + activeAt = "2026-05-13T10:48:54.554Z", + ) + private val privacy = + TermModel( + id = "2", + type = "PRIVACY", + version = 1, + title = "개인정보 처리 방침", + content = "", + required = true, + activeAt = "2026-05-13T10:48:54.554Z", + ) + private val marketing = + TermModel( + id = "3", + type = "MARKETING", + version = 1, + title = "마케팅 정보 수신 동의", + content = "", + required = false, + activeAt = "2026-05-13T10:48:54.554Z", + ) + + private fun loaded() = + OnboardingTermsUiState( + termsState = UiState.Success(listOf(service, privacy, marketing)), + ) + + // ---- 체크 토글 ---- + + @Test + fun `약관을 체크하면 checkedTermIds에 반영된다`() { + val state = loaded().toggleChecked(service.id) + + assertTrue(state.isChecked(service.id)) + assertFalse(state.isChecked(privacy.id)) + } + + @Test + fun `이미 체크된 약관을 다시 누르면 해제된다`() { + val state = loaded().toggleChecked(service.id).toggleChecked(service.id) + + assertFalse(state.isChecked(service.id)) + } + + // ---- 전체 동의 ---- + + @Test + fun `전체 동의를 누르면 모든 약관이 체크된다`() { + val state = loaded().toggleAllChecked() + + assertTrue(state.isAllChecked) + assertTrue(state.isChecked(service.id)) + assertTrue(state.isChecked(privacy.id)) + assertTrue(state.isChecked(marketing.id)) + } + + @Test + fun `모두 체크된 상태에서 전체 동의를 누르면 모두 해제된다`() { + val state = loaded().toggleAllChecked().toggleAllChecked() + + assertFalse(state.isAllChecked) + assertTrue(state.checkedTermIds.isEmpty()) + } + + @Test + fun `일부만 체크된 상태에서 전체 동의를 누르면 모두 체크된다`() { + val state = loaded().toggleChecked(service.id).toggleAllChecked() + + assertTrue(state.isAllChecked) + } + + // ---- 동의하기 버튼 활성화 ---- + + @Test + fun `필수 약관이 모두 체크되어야 진행할 수 있다`() { + val onlyOneRequired = loaded().toggleChecked(service.id) + assertFalse(onlyOneRequired.canProceed) + + val allRequired = onlyOneRequired.toggleChecked(privacy.id) + assertTrue(allRequired.canProceed) + } + + @Test + fun `선택 약관은 진행 가능 여부에 영향을 주지 않는다`() { + val state = loaded().toggleChecked(marketing.id) + + assertFalse(state.canProceed) + } + + @Test + fun `약관이 로드되지 않았으면 진행할 수 없다`() { + assertFalse(OnboardingTermsUiState().canProceed) + assertFalse(OnboardingTermsUiState().isAllChecked) + } + + // ---- 동의한 약관 ID 수집 ---- + + @Test + fun `체크된 약관의 id만 동의 목록으로 수집된다`() { + val state = loaded().toggleChecked(service.id).toggleChecked(marketing.id) + + assertEquals(listOf(service.id, marketing.id), state.agreedIds) + } + + // ---- QA 2차 1, 2: 화면 재진입 시 상태 유지 ---- + + @Test + fun `자세히 보기에서 복귀해도 체크 상태가 유지된다`() { + // 약관을 펼치고 체크한 뒤 '자세히 보기'로 이동한 상태 + val beforeLeaving = + loaded() + .toggleExpanded(service.id) + .toggleChecked(service.id) + .toggleChecked(privacy.id) + + // OnboardingViewModel은 nav graph에 스코프되어 있으므로 + // TermsDetail을 다녀와도 동일한 상태 인스턴스가 유지된다. + val afterReturning = beforeLeaving + + assertTrue(afterReturning.isChecked(service.id)) + assertTrue(afterReturning.isChecked(privacy.id)) + assertTrue(afterReturning.isExpanded(service.id)) + assertTrue(afterReturning.canProceed) + } + + @Test + fun `약관 목록이 다시 로드되어도 체크 상태가 초기화되지 않는다`() { + val checked = loaded().toggleChecked(service.id).toggleChecked(privacy.id) + + // 재진입으로 loadTerms()가 다시 성공해 새 리스트 인스턴스가 들어온 경우 + val reloaded = + checked.copy( + termsState = UiState.Success(listOf(service, privacy, marketing)), + ) + + assertTrue(reloaded.isChecked(service.id)) + assertTrue(reloaded.isChecked(privacy.id)) + assertTrue(reloaded.canProceed) + } + + @Test + fun `펼침 상태도 화면 재진입 사이에 유지된다`() { + val state = loaded().toggleExpanded(marketing.id) + + assertTrue(state.isExpanded(marketing.id)) + assertFalse(state.isExpanded(service.id)) + + assertFalse(state.toggleExpanded(marketing.id).isExpanded(marketing.id)) + } +}