diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorContent.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorContent.kt index f91856fb12..8e78b9654c 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorContent.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorContent.kt @@ -52,6 +52,7 @@ internal fun EditorContent( typeContent: @Composable () -> Unit, modifier: Modifier = Modifier, isPrefillLoading: Boolean = false, + isTypeSelectionEnabled: Boolean = true, ) { Column( modifier = @@ -71,6 +72,7 @@ internal fun EditorContent( onValueSelected = state::onTypeSelected, expanded = state.typeDropdownExpanded, onExpandedChange = state::onTypeDropdownExpandedChange, + enabled = isTypeSelectionEnabled, menuStyle = DropdownMenuStyle( shadowElevation = 10.dp, @@ -124,6 +126,7 @@ fun AfternoteEditorBody( snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, isPrefillLoading: Boolean = false, + isTypeSelectionEnabled: Boolean = true, ) { // 슬롯을 누르면 곧장 갤러리가 뜨는 대신 "갤러리에서 선택 / 촬영" 시트를 한 단계 끼운다 (#369). val mediaSourceState = @@ -151,6 +154,7 @@ fun AfternoteEditorBody( }, modifier = modifier, isPrefillLoading = isPrefillLoading, + isTypeSelectionEnabled = isTypeSelectionEnabled, ) MemorialMediaSourceSheet(state = mediaSourceState) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt index edd12825ae..f88c6617ce 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt @@ -55,6 +55,10 @@ fun AfternoteEditorScreen( onRegisterClick: () -> Unit, snackbarMessage: String?, onSnackbarMessageConsumed: () -> Unit, + // 스낵바 쌍과 같은 이유로 기본값을 주지 않는다: 새 진입 경로가 검증 팝업 배선을 빠뜨리면 + // 조용히 팝업 없는 에디터가 되는 대신 컴파일 단계에서 걸리게 한다. + validationMessage: String?, + onValidationMessageConsumed: () -> Unit, content: @Composable (SnackbarHostState) -> Unit, modifier: Modifier = Modifier, state: AfternoteEditorState = rememberAfternoteEditorState(), @@ -164,6 +168,15 @@ fun AfternoteEditorScreen( dismissText = stringResource(R.string.afternote_editor_exit_confirm_cancel), ) } + + if (validationMessage != null) { + Popup( + type = PopupType.Default, + message = validationMessage, + onConfirm = onValidationMessageConsumed, + onDismiss = onValidationMessageConsumed, + ) + } } } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidator.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidator.kt index 75a1c432d1..05bfa1f5f9 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidator.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidator.kt @@ -8,55 +8,49 @@ import com.afternote.feature.afternote.presentation.author.editor.state.EditorFo /** * 애프터노트 저장 전 필수 필드 검증. * - * 카테고리별 필수 입력 조건을 검사하여 첫 번째 오류를 반환합니다. + * 카테고리별 필수 입력 조건을 모두 검사한다. 누락이 하나면 해당 필드 오류를, 둘 이상이면 + * [AfternoteValidationError.MULTIPLE_REQUIRED_FIELDS]를 반환한다. */ internal object AfternoteEditorValidator { fun validate( form: EditorFormState, payload: RegisterAfternotePayload, - selectedReceiverIds: List, ): AfternoteValidationError? { val type = form.selectedType - // 미구현 placeholder 카테고리는 입력 상태와 무관하게 저장 불가 — 개별 필드 검증(수신자·서비스명)보다 먼저 차단해 + // 미구현 placeholder 카테고리는 입력 상태와 무관하게 저장 불가 — 개별 필드 검증(서비스명 등)보다 먼저 차단해 // "서비스명을 선택하라" 류의 그릴 수도 없는 필드에 대한 안내가 나가지 않게 한다. if (type == AfternoteType.ESTATE) { return AfternoteValidationError.UNIMPLEMENTED_TYPE } - if (selectedReceiverIds.isEmpty()) { - return AfternoteValidationError.RECEIVERS_REQUIRED - } - if (payload.serviceName.trim().isEmpty()) { - return AfternoteValidationError.TITLE_REQUIRED - } + val errors = mutableListOf() + if (payload.serviceName.trim().isEmpty()) errors += AfternoteValidationError.TITLE_REQUIRED // 아무것도 안 쓴 빈 칸은 저장 시 버려지지만, 제목만 채운 블록은 서버가 400 으로 거절한다. if (payload.messageBlocks.any { it.title.isNotBlank() && it.body.isBlank() }) { - return AfternoteValidationError.LEAVE_MESSAGE_BODY_REQUIRED + errors += AfternoteValidationError.LEAVE_MESSAGE_BODY_REQUIRED } - return when (type) { - // BUSINESS 는 시안(700:38735)의 필수 항목(계정 정보·처리 방법)이 SOCIAL 과 동일해 같은 규칙을 쓴다. - AfternoteType.SOCIAL_NETWORK, AfternoteType.BUSINESS -> validateAccount(payload) + when (type) { + // 필수 판정 기준은 서버 계약(정식 등록 타이트 검증)이다 — 계정형은 계정 정보만 서버가 요구하고, + // 처리 방법(actions)·수신자는 서버도 「생략 또는 빈 배열 가능」인 선택 항목이다. + AfternoteType.SOCIAL_NETWORK, AfternoteType.BUSINESS -> { + if (payload.accountId.isBlank() || payload.password.isBlank()) { + errors += AfternoteValidationError.ACCOUNT_CREDENTIALS_REQUIRED + } + } - AfternoteType.GALLERY_AND_FILES -> validateProcessingMethods(payload) + AfternoteType.GALLERY_AND_FILES -> {} - AfternoteType.MEMORIAL -> null + // 서버는 정식 등록 시 추모 플레이리스트 곡을 필수로 검증한다(PLAYLIST_SONGS_REQUIRED) — + // 확정 안내 문구가 없어 FE 검증 신설은 #1389 에서 다룬다. + AfternoteType.MEMORIAL -> {} // ESTATE 는 디자인 확정 전 placeholder 만 노출. UI 자체에서 입력이 막혀 있지만 // 안전망으로 Validator 에서도 저장을 차단한다. - AfternoteType.ESTATE -> AfternoteValidationError.UNIMPLEMENTED_TYPE + AfternoteType.ESTATE -> {} } - } - - private fun validateAccount(payload: RegisterAfternotePayload): AfternoteValidationError? { - if (payload.accountId.isBlank() || payload.password.isBlank()) { - return AfternoteValidationError.ACCOUNT_CREDENTIALS_REQUIRED - } - return validateProcessingMethods(payload) - } - - private fun validateProcessingMethods(payload: RegisterAfternotePayload): AfternoteValidationError? { - if (payload.processingMethods.isEmpty()) { - return AfternoteValidationError.PROCESSING_METHODS_REQUIRED + return when (errors.size) { + 0 -> null + 1 -> errors.single() + else -> AfternoteValidationError.MULTIPLE_REQUIRED_FIELDS } - return null } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt index 81d2d0d12b..c2972d37ba 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt @@ -387,7 +387,6 @@ class AfternoteEditorViewModel AfternoteEditorValidator.validate( form = form, payload = payload, - selectedReceiverIds = selectedReceiverIds, ) if (validationError != null) { internalState.update { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodListSection.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodListSection.kt index 591362bc02..cafe3853e4 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodListSection.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodListSection.kt @@ -28,7 +28,7 @@ fun ProcessingMethodListSection( ) { EditorSectionLabel( text = stringResource(R.string.afternote_editor_label_process_method_list), - isRequired = true, + isRequired = false, ) ProcessingMethodList( items = section.items, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/receiver/RecipientDesignationSection.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/receiver/RecipientDesignationSection.kt index 7d00c74af0..c1bae5acb0 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/receiver/RecipientDesignationSection.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/receiver/RecipientDesignationSection.kt @@ -29,7 +29,7 @@ fun RecipientDesignationSection( ) { EditorSectionLabel( text = stringResource(R.string.afternote_editor_label_receiver_add), - isRequired = true, + isRequired = false, ) AfternoteEditorReceiverList( afternoteEditReceivers = section.afternoteEditReceivers, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorSelectionDropdown.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorSelectionDropdown.kt index bc0a6fd3b6..3a59e1591d 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorSelectionDropdown.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorSelectionDropdown.kt @@ -63,6 +63,7 @@ data class DropdownMenuStyle( * @param onExpandedChange Callback invoked when the user requests to open/close the menu * @param modifier Modifier for the component * @param isRequired 라벨에 필수 표시(*) 노출 여부 + * @param enabled false이면 선택 앵커와 메뉴를 비활성화하고 드롭다운 셰브론을 숨긴다 * @param placeholder [optionLabel]로 변환한 [selectedValue]가 비어 있을 때 앵커에 흐리게(gray5) 노출할 미선택 안내 문구 * @param menuStyle Style configuration for the dropdown menu */ @@ -78,6 +79,7 @@ fun EditorSelectionDropdown( onExpandedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, isRequired: Boolean = false, + enabled: Boolean = true, placeholder: String? = null, menuStyle: DropdownMenuStyle = DropdownMenuStyle(), ) { @@ -98,15 +100,17 @@ fun EditorSelectionDropdown( // Material 3 ExposedDropdownMenuBox: 앵커–메뉴 너비·접근성, 서브컴포지션 없이 처리 ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = onExpandedChange, + expanded = expanded && enabled, + onExpandedChange = { if (enabled) onExpandedChange(it) }, modifier = Modifier.fillMaxWidth(), ) { Row( modifier = Modifier - .menuAnchor(type = ExposedDropdownMenuAnchorType.PrimaryNotEditable) - .fillMaxWidth() + .menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = enabled, + ).fillMaxWidth() .bottomBorder(color = AfternoteDesign.colors.gray3, width = 0.58.dp) .padding(top = 4.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, @@ -123,15 +127,17 @@ fun EditorSelectionDropdown( ), ) - Icon( - painter = painterResource(R.drawable.feature_afternote_ic_dropdown_vector), - contentDescription = stringResource(R.string.afternote_editor_content_description_dropdown), - tint = AfternoteDesign.colors.gray8, - ) + if (enabled) { + Icon( + painter = painterResource(R.drawable.feature_afternote_ic_dropdown_vector), + contentDescription = stringResource(R.string.afternote_editor_content_description_dropdown), + tint = AfternoteDesign.colors.gray8, + ) + } } ExposedDropdownMenu( - expanded = expanded, + expanded = expanded && enabled, onDismissRequest = { onExpandedChange(false) }, modifier = Modifier.offset(y = menuStyle.menuOffset), shape = menuStyle.shape, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteSaveState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteSaveState.kt index 35a2d885ca..0bc445a3a9 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteSaveState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteSaveState.kt @@ -12,8 +12,8 @@ enum class AfternoteValidationError( TITLE_REQUIRED(R.string.afternote_validation_title_required), ACCOUNT_CREDENTIALS_REQUIRED(R.string.afternote_validation_account_credentials_required), - /** 처리 방법 1개 이상 필요 (계정·갤러리 폼 공통). */ - PROCESSING_METHODS_REQUIRED(R.string.afternote_validation_processing_methods_required), + /** 둘 이상의 필수 입력이 동시에 비어 있어 특정 필드 하나로 안내를 좁힐 수 없음. */ + MULTIPLE_REQUIRED_FIELDS(R.string.afternote_validation_multiple_required_fields), /** ESTATE 등 디자인 미확정으로 placeholder 만 노출되는 카테고리에서 저장 시도 시. */ UNIMPLEMENTED_TYPE(R.string.afternote_validation_unimplemented_category), @@ -32,7 +32,7 @@ enum class AfternoteValidationError( * 에디터에서 UI가 소비할 단일 오류 상태. * * nullable [AfternoteEditorUiState.error]의 `null`은 오류가 없는 정상 상태이고, 값이 있을 때만 - * 오류 종류에 맞는 안내를 노출한다. 화면 표현은 디자인 확정 전까지 기존 Snackbar를 유지한다. + * 오류 종류에 맞는 안내를 노출한다. 검증 오류는 확인 팝업, 네트워크·서버·업로드 오류는 Snackbar로 표시한다. */ sealed interface AfternoteEditorError { data class Validation( diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphEditor.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphEditor.kt index 24d176c749..1c7bd57389 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphEditor.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphEditor.kt @@ -175,7 +175,25 @@ internal fun AfternoteEditorNavigation( } val errorEvent = uiState.errorEvent - val snackbarMessage = errorEvent?.error?.let { stringResource(it.messageResId()) } + // 오류 하나는 정확히 한 채널로만 간다 — 검증 실패는 확인 팝업, 그 외 전부는 스낵바. + val validationMessage: String? + val snackbarMessage: String? + when (val error = errorEvent?.error) { + null -> { + validationMessage = null + snackbarMessage = null + } + + is AfternoteEditorError.Validation -> { + validationMessage = stringResource(error.messageResId()) + snackbarMessage = null + } + + else -> { + validationMessage = null + snackbarMessage = stringResource(error.messageResId()) + } + } val onRegisterClick = remember(editViewModel, state) { @@ -192,6 +210,10 @@ internal fun AfternoteEditorNavigation( onSnackbarMessageConsumed = { errorEvent?.let(editViewModel::onErrorConsumed) }, + validationMessage = validationMessage, + onValidationMessageConsumed = { + errorEvent?.let(editViewModel::onErrorConsumed) + }, content = { snackbarHostState -> AfternoteEditorBody( state = state, @@ -203,6 +225,7 @@ internal fun AfternoteEditorNavigation( onCaptureFailed = editViewModel::onMemorialCaptureLaunchFailed, snackbarHostState = snackbarHostState, isPrefillLoading = uiState.isPrefillLoading, + isTypeSelectionEnabled = !editViewModel.isEditing, ) }, state = state, diff --git a/feature/afternote/presentation/src/main/res/values/strings.xml b/feature/afternote/presentation/src/main/res/values/strings.xml index 01e3981720..4061746dc4 100644 --- a/feature/afternote/presentation/src/main/res/values/strings.xml +++ b/feature/afternote/presentation/src/main/res/values/strings.xml @@ -246,10 +246,10 @@ 삭제 중 항목을 찾을 수 없습니다. - 등록할 서비스(플랫폼)명을 선택해 주세요. + 서비스명: 애프터노트를 등록하려면 서비스를 먼저 선택해 주세요. 등록할 서비스 - 계정 ID와 비밀번호를 입력해 주세요. - 처리 방법을 한 가지 이상 추가해 주세요. + 계정정보: 계정 정보가 입력되지 않으면 저장할 수 없습니다. + 입력하지 않은 항목이 있습니다. 확인 후 다시 시도해 주세요. 수신자를 한 명 이상 선택해 주세요. 남기실 말씀의 내용을 입력해 주세요. 추억 플레이리스트 곡 diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/SelectionDropdownScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/SelectionDropdownScreenshotTest.kt index 2481e65eb4..9cfa1c8dcf 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/SelectionDropdownScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/SelectionDropdownScreenshotTest.kt @@ -60,3 +60,24 @@ internal fun selectionDropdownSelectedScreenshot() { } } } + +// 수정 모드의 종류 필드 — 값과 하단선은 유지하되 드롭다운 셰브론과 선택 동작은 제거한다 (#951). +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun selectionDropdownDisabledScreenshot() { + AfternoteTheme { + Box(modifier = Modifier.padding(24.dp)) { + EditorSelectionDropdown( + label = stringResource(R.string.afternote_editor_label_category), + selectedValue = stringResource(R.string.afternote_editor_category_social), + options = emptyList(), + optionLabel = { it }, + onValueSelected = {}, + expanded = false, + onExpandedChange = {}, + enabled = false, + ) + } + } +} diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithRecipientScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithRecipientScreenshot_748aa731_0.png index 30aafcb330..5dc0bfda4c 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithRecipientScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithRecipientScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithoutRecipientScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithoutRecipientScreenshot_748aa731_0.png index a348d76947..bab4dfdfae 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithoutRecipientScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/account/AccountEditorContentScreenshotTestKt/accountEditorContentWithoutRecipientScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/gallery/GalleryAndFileEditorContentScreenshotTestKt/galleryAndFileEditorContentScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/gallery/GalleryAndFileEditorContentScreenshotTestKt/galleryAndFileEditorContentScreenshot_748aa731_0.png index 9153ef3949..321b0e0ca0 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/gallery/GalleryAndFileEditorContentScreenshotTestKt/galleryAndFileEditorContentScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/gallery/GalleryAndFileEditorContentScreenshotTestKt/galleryAndFileEditorContentScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialEditorContentScreenshotTestKt/memorialEditorContentScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialEditorContentScreenshotTestKt/memorialEditorContentScreenshot_748aa731_0.png index 8c169b97a4..5bf6c1d84c 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialEditorContentScreenshotTestKt/memorialEditorContentScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialEditorContentScreenshotTestKt/memorialEditorContentScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/selection/SelectionDropdownScreenshotTestKt/selectionDropdownDisabledScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/selection/SelectionDropdownScreenshotTestKt/selectionDropdownDisabledScreenshot_748aa731_0.png new file mode 100644 index 0000000000..2a9ae63d33 Binary files /dev/null and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/selection/SelectionDropdownScreenshotTestKt/selectionDropdownDisabledScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt index 0eac6c389b..d543511beb 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorExtendedTest.kt @@ -152,20 +152,29 @@ class AfternoteAuthorExtendedTest { } @Test - fun repeatedValidation_afterSnackbarDismiss_showsAgainWithoutSaveCall() { + fun repeatedValidation_afterPopupConfirm_showsAgainWithoutSaveCall() { val repository = FakeAfternoteRepository.strict().apply { onGetDetail = { - Result.success(authorDetail().copy(receivers = emptyList())) + // 수신자는 선택 항목(#951)이라 비어 있어도 오류에 끼지 않는다 — 계정정보 누락만 단일 오류로 떠야 한다. + Result.success( + authorDetail().copy( + receivers = emptyList(), + content = + DetailContent.SocialNetwork( + credentials = DetailCredentials(id = "", password = ""), + processingMethods = listOf("계정 삭제"), + ), + ), + ) } } val viewModel = editorViewModel(repository, itemId = 73L) var editorState: AfternoteEditorState? = null - var snackbarHostState: SnackbarHostState? = null val validationMessage = RuntimeEnvironment .getApplication() - .getString(R.string.afternote_validation_receivers_required) + .getString(R.string.afternote_validation_account_credentials_required) composeRule.setContent { AfternoteTheme { @@ -173,15 +182,13 @@ class AfternoteAuthorExtendedTest { itemId = 73L, viewModel = viewModel, onStateReady = { editorState = it }, - onSnackbarHostReady = { snackbarHostState = it }, ) } } composeRule.waitUntil(timeoutMillis = 5_000) { !viewModel.uiState.value.isPrefillLoading && - editorState != null && - snackbarHostState != null + editorState != null } composeRule.onNodeWithText("Instagram").assertIsDisplayed() val topBarRegister = @@ -194,11 +201,9 @@ class AfternoteAuthorExtendedTest { val firstOccurrence = requireNotNull(viewModel.uiState.value.errorEvent).occurrence assertEquals(0, repository.updateCalls.size) - composeRule.runOnIdle { - requireNotNull(snackbarHostState).currentSnackbarData?.dismiss() - } + composeRule.onNodeWithText("확인").performClick() composeRule.waitUntil(timeoutMillis = 5_000) { - snackbarHostState?.currentSnackbarData == null && viewModel.uiState.value.errorEvent == null + viewModel.uiState.value.errorEvent == null } composeRule.onNodeWithText(validationMessage).assertDoesNotExist() @@ -294,7 +299,6 @@ private fun AuthorEditorForUpdate( itemId: Long, viewModel: AfternoteEditorViewModel, onStateReady: (AfternoteEditorState) -> Unit, - onSnackbarHostReady: (SnackbarHostState) -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val state = @@ -344,15 +348,18 @@ private fun AuthorEditorForUpdate( memorialMedia = SaveAfternoteMemorialMedia(), ) }, - snackbarMessage = + snackbarMessage = null, + validationMessage = (errorEvent?.error as? AfternoteEditorError.Validation)?.let { stringResource(it.reason.messageResId) }, onSnackbarMessageConsumed = { errorEvent?.let(viewModel::onErrorConsumed) }, + onValidationMessageConsumed = { + errorEvent?.let(viewModel::onErrorConsumed) + }, content = { editorSnackbarHostState -> - onSnackbarHostReady(editorSnackbarHostState) AfternoteEditorBody( state = state, form = uiState.form, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorTest.kt index 93e8dd26c2..4746a1e1d1 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/AfternoteAuthorTest.kt @@ -2,10 +2,7 @@ package com.afternote.feature.afternote.presentation.author import androidx.compose.material3.Text import androidx.compose.runtime.getValue -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule -import androidx.compose.ui.test.onNodeWithText import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.theme.AfternoteTheme @@ -20,8 +17,6 @@ import com.afternote.feature.afternote.presentation.author.editor.AfternoteEdito import com.afternote.feature.afternote.presentation.author.editor.SaveAfternoteMemorialMedia import com.afternote.feature.afternote.presentation.author.editor.message.EditorMessageTextBlock import com.afternote.feature.afternote.presentation.author.editor.model.RegisterAfternotePayload -import com.afternote.feature.afternote.presentation.author.editor.state.AfternoteEditorError -import com.afternote.feature.afternote.presentation.author.editor.state.AfternoteValidationError import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Rule @@ -39,16 +34,16 @@ class AfternoteAuthorTest { val composeRule = createComposeRule() @Test - fun missingReceiver_blocksSaveAndExposesValidationSemantics() { - val repository = FakeAfternoteRepository.strict() + fun emptyReceivers_saveProceedsWithoutValidationError() { + // 수신자는 전 카테고리 선택 항목(#951 합의·서버 계약) — 비워 둔 채로 저장이 그대로 진행돼야 한다. + val repository = + FakeAfternoteRepository.strict().apply { + onCreateSocial = { Result.success(41L) } + } val viewModel = viewModel(repository, afternoteEditorSavedStateHandle(AfternoteType.SOCIAL_NETWORK)) composeRule.setContent { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - AfternoteTheme { - (uiState.error as? AfternoteEditorError.Validation)?.let { - Text(stringResource(it.reason.messageResId)) - } - } + AfternoteTheme { Text(uiState.savedId?.toString().orEmpty()) } } composeRule.runOnIdle { @@ -59,13 +54,10 @@ class AfternoteAuthorTest { memorialMedia = SaveAfternoteMemorialMedia(), ) } + composeRule.waitUntil(timeoutMillis = 5_000) { viewModel.uiState.value.savedId == 41L } - composeRule.onNodeWithText("수신자를 한 명 이상 선택해 주세요.").assertIsDisplayed() - assertEquals(0, repository.socialPayloads.size) - assertEquals( - AfternoteEditorError.Validation(AfternoteValidationError.RECEIVERS_REQUIRED), - viewModel.uiState.value.error, - ) + assertNull(viewModel.uiState.value.error) + assertEquals(emptyList(), repository.socialPayloads.single().receiverIds) } @Test diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorProcessingMethodDefaultsInteractionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorProcessingMethodDefaultsInteractionTest.kt index ccaffc436b..1a5cfbc800 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorProcessingMethodDefaultsInteractionTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorProcessingMethodDefaultsInteractionTest.kt @@ -39,6 +39,8 @@ class AfternoteEditorProcessingMethodDefaultsInteractionTest { onRegisterClick = {}, snackbarMessage = null, onSnackbarMessageConsumed = {}, + validationMessage = null, + onValidationMessageConsumed = {}, content = {}, shouldDeferBaselineCapture = shouldDeferEditorBaselineCapture( diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidationDialogTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidationDialogTest.kt new file mode 100644 index 0000000000..0efadd7e68 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidationDialogTest.kt @@ -0,0 +1,55 @@ +package com.afternote.feature.afternote.presentation.author.editor + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.presentation.author.editor.state.EditorFormState +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AfternoteEditorValidationDialogTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `검증 실패는 확인 팝업으로 표시하고 확인 시 소비한다`() { + var consumed = 0 + val message = "서비스명: 애프터노트를 등록하려면 서비스를 먼저 선택해 주세요." + var validationMessage by mutableStateOf(message) + + composeRule.setContent { + AfternoteTheme { + AfternoteEditorScreen( + form = EditorFormState(), + onBackClick = {}, + onRegisterClick = {}, + snackbarMessage = null, + onSnackbarMessageConsumed = {}, + content = {}, + validationMessage = validationMessage, + onValidationMessageConsumed = { + consumed += 1 + validationMessage = null + }, + ) + } + } + + composeRule.onNodeWithText(message).assertIsDisplayed() + composeRule.onNodeWithText("확인").performClick() + + composeRule.runOnIdle { assertEquals(1, consumed) } + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidationEventTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidationEventTest.kt index 8a1a78045a..66fb376d25 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidationEventTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidationEventTest.kt @@ -87,7 +87,14 @@ class AfternoteEditorValidationEventTest { private fun AfternoteEditorViewModel.saveInvalidSocialAfternote() { saveAfternote( - payload = RegisterAfternotePayload(serviceName = "", date = "2026.08.27"), + payload = + RegisterAfternotePayload( + serviceName = "", + date = "2026.08.27", + accountId = "account", + password = "password", + processingMethods = listOf("계정 삭제"), + ), selectedReceiverIds = listOf(1L), memorialMedia = SaveAfternoteMemorialMedia(), ) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidatorTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidatorTest.kt index c3a934b2f0..ecda0518d6 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidatorTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorValidatorTest.kt @@ -20,35 +20,43 @@ class AfternoteEditorValidatorTest { val error = AfternoteEditorValidator.validate( form = EditorFormState(typeForm = AfternoteTypeForm.Social()), - payload = payload.copy(processingMethods = listOf("계정 삭제")), - selectedReceiverIds = listOf(1L), + payload = payload, ) assertEquals(AfternoteValidationError.ACCOUNT_CREDENTIALS_REQUIRED, error) } @Test - fun `계정형 폼은 처리 방법이 한 개 이상 필요하다`() { + fun `계정형 폼은 처리 방법이 없어도 저장할 수 있다`() { val error = AfternoteEditorValidator.validate( form = EditorFormState(typeForm = AfternoteTypeForm.Social()), payload = payload.copy(accountId = "account", password = "password"), - selectedReceiverIds = listOf(1L), ) - assertEquals(AfternoteValidationError.PROCESSING_METHODS_REQUIRED, error) + assertNull(error) } @Test - fun `갤러리 폼은 처리 방법이 한 개 이상 필요하다`() { + fun `갤러리 폼은 처리 방법이 없어도 저장할 수 있다`() { val error = AfternoteEditorValidator.validate( form = EditorFormState(typeForm = AfternoteTypeForm.Gallery()), payload = payload, - selectedReceiverIds = listOf(1L), ) - assertEquals(AfternoteValidationError.PROCESSING_METHODS_REQUIRED, error) + assertNull(error) + } + + @Test + fun `필수 입력이 둘 이상 비면 복수 누락 오류를 반환한다`() { + val error = + AfternoteEditorValidator.validate( + form = EditorFormState(typeForm = AfternoteTypeForm.Social()), + payload = payload.copy(serviceName = ""), + ) + + assertEquals(AfternoteValidationError.MULTIPLE_REQUIRED_FIELDS, error) } @Test @@ -57,7 +65,6 @@ class AfternoteEditorValidatorTest { AfternoteEditorValidator.validate( form = EditorFormState(typeForm = AfternoteTypeForm.Memorial()), payload = payload, - selectedReceiverIds = listOf(1L), ) assertNull(error) diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorLeaveMessageBlocksTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorLeaveMessageBlocksTest.kt index 4079f0e249..421b982865 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorLeaveMessageBlocksTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorLeaveMessageBlocksTest.kt @@ -156,8 +156,12 @@ class EditorLeaveMessageBlocksTest { private fun validateSocial(vararg blocks: EditorMessageTextBlock): AfternoteValidationError? = AfternoteEditorValidator.validate( form = EditorFormState(typeForm = AfternoteTypeForm.Social()), - payload = payloadOf(*blocks), - selectedReceiverIds = listOf(1L), + payload = + payloadOf(*blocks).copy( + accountId = "account", + password = "password", + processingMethods = listOf("계정 삭제"), + ), ) private fun payloadOf(vararg blocks: EditorMessageTextBlock) = diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorTouchTargetTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorTouchTargetTest.kt index 77bb98284f..5d3712d631 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorTouchTargetTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/EditorTouchTargetTest.kt @@ -157,6 +157,8 @@ class EditorTouchTargetTest { onRegisterClick = { registerClicks++ }, snackbarMessage = null, onSnackbarMessageConsumed = {}, + validationMessage = null, + onValidationMessageConsumed = {}, content = {}, ) } diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorSelectionDropdownInteractionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorSelectionDropdownInteractionTest.kt new file mode 100644 index 0000000000..af480b882a --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/selection/EditorSelectionDropdownInteractionTest.kt @@ -0,0 +1,51 @@ +package com.afternote.feature.afternote.presentation.author.editor.selection + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.theme.AfternoteTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class EditorSelectionDropdownInteractionTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `비활성 드롭다운은 셰브론과 클릭 동작 및 펼친 메뉴를 노출하지 않는다`() { + composeRule.setContent { + AfternoteTheme { + Box(modifier = Modifier.padding(24.dp)) { + EditorSelectionDropdown( + label = "종류", + selectedValue = "소셜 네트워크", + options = listOf("갤러리 및 파일"), + optionLabel = { it }, + onValueSelected = {}, + expanded = true, + onExpandedChange = {}, + enabled = false, + ) + } + } + } + + composeRule.onNodeWithText("소셜 네트워크").assertExists() + composeRule.onAllNodes(hasText("소셜 네트워크") and hasClickAction()).assertCountEquals(0) + composeRule.onNodeWithContentDescription("드롭다운").assertDoesNotExist() + composeRule.onNodeWithText("갤러리 및 파일").assertDoesNotExist() + } +}