diff --git a/docs/convention/composable-callback-defaults.md b/docs/convention/composable-callback-defaults.md new file mode 100644 index 0000000000..41cf59c77f --- /dev/null +++ b/docs/convention/composable-callback-defaults.md @@ -0,0 +1,73 @@ +# 컴포저블 콜백 디폴트 규칙 + +**컴포저블의 `on*` 콜백 파라미터에 no-op 디폴트(`onXxx: (...) -> Unit = {}`)를 두지 않는다.** 선택적 상호작용은 디폴트가 아니라 타입(nullable 핸들러·오버로드)으로 모델링한다. + +`app` · `feature/*` 모듈의 `src/main` 에 적용하고, `konsist` 의 `NoOpCallbackDefaultKonsistTest` 가 강제한다 (#1388). + +## 왜 금지인가 + +no-op 디폴트는 **배선을 빠뜨려도 컴파일이 통과**하게 만든다. 화면은 그려지고, 버튼도 눌리는데, 아무 일도 일어나지 않는다 — 실패가 조용해서 QA 나 사용자 신고로만 드러난다. + +실사고 전례가 반복됐다: + +- #582 · #618 · #722 — mindrecord 미배선 실사고. 콜백을 넘기지 않은 채 화면이 출고됐다. +- #777 — 137건 전수 대조로 실결함을 적출했지만, **디폴트 자체가 남아 있어 같은 부류가 재발할 통로가 열려 있었다.** 신설 코드가 패턴을 답습하는 것도 확인됐다(PR #1336 의 `onVideoClick`). +- #778 (open) — `TimeLetterWriteScreen.onTextStyleClick` 이 지금도 미배선 의심으로 추적 중이다. + +디폴트가 없으면 같은 실수가 **컴파일 에러**로 드러난다. 폴백보다 값 명시·타입으로 강제한다는 #934 방침과 같은 방향이다. + +## 처분 기준 + +### 화면 컴포저블 — 디폴트 전면 제거 + +실호출부가 NavGraph 한 곳인 화면 컴포저블은 콜백을 전부 required 로 한다. 프리뷰 · screenshotTest · unit test 는 `{}` 를 **명시**한다 (#602 전례). + +```kotlin +// Before +fun GalleryDetailScreen( + onBackClick: () -> Unit, + onEditClick: () -> Unit = {}, // 배선 누락이 조용히 no-op +) + +// After +fun GalleryDetailScreen( + onBackClick: () -> Unit, + onEditClick: () -> Unit, // 누락 = 컴파일 에러 +) + +// Preview / screenshotTest +GalleryDetailScreen(onBackClick = {}, onEditClick = {}) +``` + +`{}` 명시는 디폴트와 렌더링이 동일하므로 스크린샷 baseline 이 유지된다. reference PNG 재생성이 필요한 처분이라면 그 처분이 틀린 것이다(골든 정본은 CI 컨테이너 — `docs/testing` 참고). + +### 다중 호출부 리프 컴포넌트 — 건별 판정 + +- **전 호출부가 실값을 넘긴다** → 디폴트만 제거한다. 선택성이 실재하지 않았던 것이다. +- **상호작용이 진짜 선택적이다** → `= {}` 로 "눌러도 아무 일 없는 버튼"을 그리는 대신, 상호작용 UI 자체를 접는다: + +```kotlin +// nullable 핸들러 + UI 숨김 (EditDropdownMenu, #1388) +fun EditDropdownMenu( + onDeleteClick: () -> Unit, + onEditClick: (() -> Unit)?, // null = 편집 항목을 그리지 않는다 +) { + if (onEditClick != null) { + CustomDropdownItem(text = "수정", onClick = onEditClick) + } +} +``` + +nullable 핸들러에도 **`= null` 디폴트를 두지 않는다** — 호출부가 "핸들러 있음 / 의도적으로 없음" 을 매번 명시해야 미배선과 의도적 생략이 구분된다. `showEditItem: Boolean` 같은 플래그 + no-op 콜백 짝은 nullable 핸들러 하나로 합친다(플래그와 콜백이 어긋나는 상태를 타입에서 제거). + +상호작용 조합이 많아 nullable 이 지저분해지면 오버로드 분리(상호작용 있는 시그니처 / 없는 시그니처)를 쓴다. + +### 콜백 홀더 클래스도 같다 + +`ReceiverHomeActions` 처럼 콜백을 프로퍼티로 묶은 클래스의 `val onXxx: () -> Unit = {}` 도 같은 방식으로 미배선을 숨긴다. 가드가 클래스 주 생성자까지 본다. + +## 강제 수단 + +`konsist/src/test/kotlin/com/afternote/konsist/NoOpCallbackDefaultKonsistTest.kt` 가 app · feature `src/main` 의 `@Composable` 함수 파라미터와 클래스 주 생성자 파라미터를 스캔해, `on`+대문자 이름에 no-op 람다 기본값(`{}` · `{ }` · `{ _ -> }`)이 있으면 실패시킨다. + +가드 도입 시점의 잔여 파일은 테스트 안 `LEGACY_NO_OP_DEFAULT_FILES` 에 있다. 목록의 파일은 위반이 있어도 없어도 통과한다(관대 판정 — 청소 PR 과 목록 갱신 PR 의 머지 순서가 develop 을 red 로 만들지 않게). 모듈 담당별 후속 PR(#1388 의 자식 이슈)이 청소하면서 목록에서 빼면 되고, 빼는 걸 잊으면 「해소된 항목은 경고로 알린다」 테스트가 CI 로그에 경고를 남긴다. diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/GalleryDetailScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/GalleryDetailScreen.kt index bad488135d..f23e1b369b 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/GalleryDetailScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/GalleryDetailScreen.kt @@ -60,8 +60,8 @@ fun GalleryDetailScreen( content: GalleryDetailContent = GalleryDetailContent(), snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, isEditable: Boolean = true, - onEditClick: () -> Unit = {}, - onDeleteConfirm: () -> Unit = {}, + onEditClick: () -> Unit, + onDeleteConfirm: () -> Unit, state: AfternoteDetailState = rememberAfternoteDetailState(), ) { if (isEditable && state.showDeleteDialog) { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/account/AccountDetailScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/account/AccountDetailScreen.kt index 5506d1a497..0123de84e2 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/account/AccountDetailScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/account/AccountDetailScreen.kt @@ -141,8 +141,8 @@ fun AccountDetailScreen( content: AccountDetailContent = AccountDetailContent(), snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, isEditable: Boolean = true, - onEditClick: () -> Unit = {}, - onDeleteConfirm: () -> Unit = {}, + onEditClick: () -> Unit, + onDeleteConfirm: () -> Unit, state: AfternoteDetailState = rememberAfternoteDetailState(), ) { if (isEditable && state.showDeleteDialog) { 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 2400a3139c..4a10aac705 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 @@ -277,7 +277,7 @@ internal fun AfternoteTypeContent( onPhotoAddClick: () -> Unit, onVideoAddClick: () -> Unit, onThumbnailBytesReady: (ByteArray?) -> Unit, - onThumbnailExtractionFailed: (Throwable) -> Unit = {}, + onThumbnailExtractionFailed: (Throwable) -> Unit, ) { when (form.selectedType) { AfternoteType.MEMORIAL -> { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialPhotoUpload.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialPhotoUpload.kt index cf6bd3c502..bc105afc72 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialPhotoUpload.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialPhotoUpload.kt @@ -19,7 +19,7 @@ fun MemorialPhotoUpload( modifier: Modifier = Modifier, label: String? = null, displayImageUri: String? = null, - onAddPhotoClick: () -> Unit = {}, + onAddPhotoClick: () -> Unit, ) { val labelText = label ?: stringResource(R.string.afternote_editor_memorial_photo_label) Column( diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialVideoUpload.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialVideoUpload.kt index a9ed303693..d898070e36 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialVideoUpload.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialVideoUpload.kt @@ -71,8 +71,8 @@ fun MemorialVideoUpload( videoUrl: String? = null, thumbnailUrl: String? = null, onAddVideoClick: () -> Unit, - onThumbnailBytesReady: (ByteArray?) -> Unit = {}, - onThumbnailExtractionFailed: (Throwable) -> Unit = {}, + onThumbnailBytesReady: (ByteArray?) -> Unit, + onThumbnailExtractionFailed: (Throwable) -> Unit, ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { val hasVideo = !videoUrl.isNullOrBlank() diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/playlist/MemorialPlaylistEntry.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/playlist/MemorialPlaylistEntry.kt index 8d270b44b3..20109bd0d8 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/playlist/MemorialPlaylistEntry.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/playlist/MemorialPlaylistEntry.kt @@ -64,10 +64,10 @@ import com.afternote.feature.afternote.presentation.shared.model.PlaylistSongDis fun MemorialPlaylistEntry( songs: List, modifier: Modifier = Modifier, - onBackClick: () -> Unit = {}, - onNavigateToAddSongScreen: () -> Unit = {}, - onClearAllSongs: () -> Unit = {}, - onRemoveSongs: (Set) -> Unit = {}, + onBackClick: () -> Unit, + onNavigateToAddSongScreen: () -> Unit, + onClearAllSongs: () -> Unit, + onRemoveSongs: (Set) -> Unit, initialEditMode: Boolean = false, initialSelectedSongKeys: Set = emptySet(), ) { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodCheckbox.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodCheckbox.kt index de00730558..81492ef1c9 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodCheckbox.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodCheckbox.kt @@ -55,11 +55,11 @@ fun ProcessingMethodCheckbox( item: ProcessingMethodItem, expanded: Boolean = false, isEditing: Boolean = false, - onMoreClick: () -> Unit = {}, - onDismissDropdown: () -> Unit = {}, - onEditClick: () -> Unit = {}, - onDeleteClick: () -> Unit = {}, - onEditConfirmed: (String) -> Unit = {}, + onMoreClick: () -> Unit, + onDismissDropdown: () -> Unit, + onEditClick: () -> Unit, + onDeleteClick: () -> Unit, + onEditConfirmed: (String) -> Unit, ) { Row( modifier = modifier.fillMaxWidth(), diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/receiver/AfternoteEditorReceiverList.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/receiver/AfternoteEditorReceiverList.kt index 8482e932ef..618d309bd1 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/receiver/AfternoteEditorReceiverList.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/receiver/AfternoteEditorReceiverList.kt @@ -72,7 +72,6 @@ fun AfternoteEditorReceiverList( onDismissDropdown = { state.expandedStates[receiver.id] = false }, - showEditItem = false, onDeleteClick = { onItemDeleteClick(receiver.id) }, ) } @@ -101,10 +100,9 @@ private fun AfternoteEditorReceiverItem( modifier: Modifier = Modifier, receiver: AfternoteEditorReceiver, expanded: Boolean = false, - onMoreClick: () -> Unit = {}, - onDismissDropdown: () -> Unit = {}, - showEditItem: Boolean = true, - onDeleteClick: () -> Unit = {}, + onMoreClick: () -> Unit, + onDismissDropdown: () -> Unit, + onDeleteClick: () -> Unit, ) { Row( modifier = @@ -145,7 +143,8 @@ private fun AfternoteEditorReceiverItem( expanded = expanded, onDismissRequest = onDismissDropdown, onDeleteClick = onDeleteClick, - showEditItem = showEditItem, + // 수신자 행 메뉴엔 편집이 없다 — null 이 편집 항목 자체를 숨긴다. + onEditClick = null, ) } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/body/infinite/content/list/item/ContentListItem.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/body/infinite/content/list/item/ContentListItem.kt index 8710125e30..7702fdc806 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/body/infinite/content/list/item/ContentListItem.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/body/infinite/content/list/item/ContentListItem.kt @@ -33,7 +33,7 @@ import com.afternote.feature.afternote.presentation.R fun AfternoteListItem( uiModel: ListItemUiModel, modifier: Modifier = Modifier, - onClick: () -> Unit = {}, + onClick: () -> Unit, ) { Row( modifier = diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/EditDropdownMenu.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/EditDropdownMenu.kt index e1cf46dc82..93f4cc7d88 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/EditDropdownMenu.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/detail/EditDropdownMenu.kt @@ -36,15 +36,18 @@ import androidx.compose.ui.window.Popup as ComposePopup * Material3 `DropdownMenu` 과 달리 `androidx.compose.ui.window.Popup` 기반이라 * 등장 페이드·스케일 애니메이션은 없다. 필요하면 메뉴 `Column` 을 * `androidx.compose.animation.AnimatedVisibility` 로 감싸면 된다. + * + * @param onEditClick 편집 항목 클릭 콜백. **null 이면 편집 항목 자체를 그리지 않는다** — 편집이 + * 없는 메뉴(수신자 행)와 "핸들러를 안 넘겨 죽은 편집 버튼이 그려지는" 미배선을 타입으로 구분하기 + * 위해 `showEditItem` 플래그 + no-op 디폴트 대신 nullable 핸들러 하나로 모델링한다 (#1388). */ @Composable fun EditDropdownMenu( expanded: Boolean, onDismissRequest: () -> Unit, onDeleteClick: () -> Unit, + onEditClick: (() -> Unit)?, modifier: Modifier = Modifier, - onEditClick: () -> Unit = {}, - showEditItem: Boolean = true, popupPositionProvider: PopupPositionProvider = rememberFixedRightPopupPositionProvider(), ) { if (!expanded) return @@ -55,11 +58,13 @@ fun EditDropdownMenu( properties = PopupProperties(focusable = true), ) { EditDropdownMenuItems( - showEditItem = showEditItem, - onEditClick = { - onDismissRequest() - onEditClick() - }, + onEditClick = + onEditClick?.let { onEdit -> + { + onDismissRequest() + onEdit() + } + }, onDeleteClick = { onDismissRequest() onDeleteClick() @@ -76,8 +81,7 @@ fun EditDropdownMenu( */ @Composable private fun EditDropdownMenuItems( - showEditItem: Boolean, - onEditClick: () -> Unit, + onEditClick: (() -> Unit)?, onDeleteClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -92,7 +96,7 @@ private fun EditDropdownMenuItems( ).background(AfternoteDesign.colors.white) .width(IntrinsicSize.Max), ) { - if (showEditItem) { + if (onEditClick != null) { CustomDropdownItem( text = stringResource(R.string.feature_afternote_menu_edit), onClick = onEditClick, diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/detail/GalleryDetailScreenScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/detail/GalleryDetailScreenScreenshotTest.kt index 6cf74d9c84..a59565ea03 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/detail/GalleryDetailScreenScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/detail/GalleryDetailScreenScreenshotTest.kt @@ -12,6 +12,8 @@ internal fun galleryDetailScreenScreenshot() { AfternoteTheme { GalleryDetailScreen( onBackClick = {}, + onEditClick = {}, + onDeleteConfirm = {}, content = GalleryDetailContent( serviceName = "직접 입력한 갤러리 서비스", diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/detail/account/AccountDetailScreenScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/detail/account/AccountDetailScreenScreenshotTest.kt index f6e63085d7..56ed55f71e 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/detail/account/AccountDetailScreenScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/detail/account/AccountDetailScreenScreenshotTest.kt @@ -13,6 +13,8 @@ internal fun accountDetailScreenScreenshot() { AfternoteTheme { AccountDetailScreen( onBackClick = {}, + onEditClick = {}, + onDeleteConfirm = {}, content = AccountDetailContent( serviceName = "직접 입력한 소셜 서비스", @@ -30,6 +32,8 @@ internal fun accountDetailBusinessFallbackScreenshot() { AfternoteTheme { AccountDetailScreen( onBackClick = {}, + onEditClick = {}, + onDeleteConfirm = {}, content = AccountDetailContent( serviceName = "직접 입력한 비즈니스 서비스", diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialPhotoUploadScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialPhotoUploadScreenshotTest.kt index cc0b6990d1..d8781f044a 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialPhotoUploadScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialPhotoUploadScreenshotTest.kt @@ -17,6 +17,7 @@ internal fun memorialPhotoUploadEmptyScreenshot() { modifier = Modifier.padding(16.dp), label = "추모 사진", displayImageUri = null, + onAddPhotoClick = {}, ) } } diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialVideoUploadScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialVideoUploadScreenshotTest.kt index f86c866350..a8a356d324 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialVideoUploadScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialVideoUploadScreenshotTest.kt @@ -19,6 +19,8 @@ internal fun memorialVideoUploadEmptyScreenshot() { videoUrl = null, thumbnailUrl = null, onAddVideoClick = {}, + onThumbnailBytesReady = {}, + onThumbnailExtractionFailed = {}, ) } } diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/playlist/MemorialPlaylistEntryScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/playlist/MemorialPlaylistEntryScreenshotTest.kt index 54aaf306b3..272d3ddfe9 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/playlist/MemorialPlaylistEntryScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/playlist/MemorialPlaylistEntryScreenshotTest.kt @@ -26,7 +26,13 @@ private fun screenshotSongs(): List = @Composable internal fun memorialPlaylistEntryEmptyScreenshot() { AfternoteTheme { - MemorialPlaylistEntry(songs = emptyList()) + MemorialPlaylistEntry( + songs = emptyList(), + onBackClick = {}, + onNavigateToAddSongScreen = {}, + onClearAllSongs = {}, + onRemoveSongs = {}, + ) } } @@ -35,7 +41,13 @@ internal fun memorialPlaylistEntryEmptyScreenshot() { @Composable internal fun memorialPlaylistEntryWithSongsScreenshot() { AfternoteTheme { - MemorialPlaylistEntry(songs = screenshotSongs()) + MemorialPlaylistEntry( + songs = screenshotSongs(), + onBackClick = {}, + onNavigateToAddSongScreen = {}, + onClearAllSongs = {}, + onRemoveSongs = {}, + ) } } @@ -46,6 +58,10 @@ internal fun memorialPlaylistEntryEditModeScreenshot() { AfternoteTheme { MemorialPlaylistEntry( songs = screenshotSongs(), + onBackClick = {}, + onNavigateToAddSongScreen = {}, + onClearAllSongs = {}, + onRemoveSongs = {}, initialEditMode = true, ) } @@ -58,6 +74,10 @@ internal fun memorialPlaylistEntryEditModeSelectionScreenshot() { AfternoteTheme { MemorialPlaylistEntry( songs = screenshotSongs(), + onBackClick = {}, + onNavigateToAddSongScreen = {}, + onClearAllSongs = {}, + onRemoveSongs = {}, initialEditMode = true, initialSelectedSongKeys = setOf("screenshot:1"), ) diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodCheckboxScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodCheckboxScreenshotTest.kt index 96df6e7392..b387f57abb 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodCheckboxScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/processing/ProcessingMethodCheckboxScreenshotTest.kt @@ -17,6 +17,11 @@ internal fun processingMethodCheckboxScreenshot() { ProcessingMethodCheckbox( item = ProcessingMethodItem(localId = 1, text = "보관"), modifier = Modifier.padding(16.dp), + onMoreClick = {}, + onDismissDropdown = {}, + onEditClick = {}, + onDeleteClick = {}, + onEditConfirmed = {}, ) } } 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 54c40c95e5..76e60ad496 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 @@ -334,6 +334,7 @@ private fun AuthorDetailForEdit( onBackClick = {}, content = account.content, onEditClick = { onEdit(state.detailId.toString()) }, + onDeleteConfirm = {}, ) } } @@ -470,6 +471,7 @@ private fun AuthorDetailForDelete( onBackClick = {}, content = account.content, snackbarHostState = snackbarHostState, + onEditClick = {}, onDeleteConfirm = { viewModel.deleteAfternote(state.detailId) }, ) } 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 5d3712d631..916acdd75e 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 @@ -115,6 +115,10 @@ class EditorTouchTargetTest { ProcessingMethodCheckbox( item = ProcessingMethodItem(1, "Method"), onMoreClick = { processingMoreClicks++ }, + onDismissDropdown = {}, + onEditClick = {}, + onDeleteClick = {}, + onEditConfirmed = {}, ) AfternoteEditorReceiverList( afternoteEditReceivers = diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialVideoUploadInteractionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialVideoUploadInteractionTest.kt index 0b75f7e177..b961da628a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialVideoUploadInteractionTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialVideoUploadInteractionTest.kt @@ -25,7 +25,11 @@ class MemorialVideoUploadInteractionTest { var addClicks = 0 composeRule.setContent { AfternoteTheme { - MemorialVideoUpload(onAddVideoClick = { addClicks += 1 }) + MemorialVideoUpload( + onAddVideoClick = { addClicks += 1 }, + onThumbnailBytesReady = {}, + onThumbnailExtractionFailed = {}, + ) } } diff --git a/konsist/build.gradle.kts b/konsist/build.gradle.kts index c1a06d05d9..55ff2b1222 100644 --- a/konsist/build.gradle.kts +++ b/konsist/build.gradle.kts @@ -1,7 +1,21 @@ plugins { id("java-library") alias(libs.plugins.jetbrains.kotlin.jvm) + // build-logic 이 이미 클래스패스에 올려 둔 플러그인이라 버전을 다시 선언하면 충돌한다. + id("org.jlleitschuh.gradle.ktlint") } + +// 이 모듈은 convention plugin(afternote.jvm.library) 대신 java-library 를 직접 쓰느라 ktlint 만 +// 빠져 있었다. resolve-pr-impact.mjs 는 «.kt 가 바뀐 모듈» 마다 `<모듈>:ktlintCheck` 를 고르므로 +// (PR #1279 이후), 태스크가 없으면 konsist 소스를 건드리는 PR 의 Ktlint job 이 태스크 선택 단계에서 +// 죽는다. build-logic 과 같은 방식으로 플러그인만 직접 적용한다. +ktlint { + version.set(libs.versions.ktlint) + filter { + exclude { it.file.path.contains("build/") } + } +} + java { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 diff --git a/konsist/src/test/kotlin/com/afternote/konsist/NoOpCallbackDefaultKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/NoOpCallbackDefaultKonsistTest.kt new file mode 100644 index 0000000000..3416c64905 --- /dev/null +++ b/konsist/src/test/kotlin/com/afternote/konsist/NoOpCallbackDefaultKonsistTest.kt @@ -0,0 +1,195 @@ +package com.afternote.konsist + +import com.lemonappdev.konsist.api.Konsist +import com.lemonappdev.konsist.api.declaration.KoFileDeclaration +import com.lemonappdev.konsist.api.declaration.KoParameterDeclaration +import org.junit.Test + +/** + * 컴포저블 no-op 콜백 디폴트 회귀 가드 (#1388). + * + * `onXxx: (...) -> Unit = {}` 는 배선을 빠뜨려도 컴파일이 통과해 조용히 no-op 이 된다 — + * "눌러도 아무 일 없는 버튼" 실사고가 #582 · #618 · #722 · #777 로 반복된 패턴이다. + * 규칙·처분 기준은 `docs/convention/composable-callback-defaults.md` 참고. + * + * ### 검사 대상 + * app · feature 모듈 `src/main` 의 + * 1. `@Composable` 함수 파라미터 + * 2. 클래스 주 생성자 파라미터 — `ReceiverHomeActions` 처럼 콜백을 프로퍼티로 묶은 + * 홀더 클래스도 같은 방식으로 미배선을 숨긴다. + * + * 이름이 `on`+대문자로 시작하고 기본값이 no-op 람다(`{}` · `{ }` · `{ _ -> }`)인 파라미터가 + * 위반이다. "함수 타입인가" 는 따로 검사하지 않는다 — no-op 람다 기본값은 함수 타입에만 + * 컴파일되므로 기본값 모양이 타입 검사를 대신하고, typealias 콜백까지 잡는다. + * + * ### 올바른 모델링 + * - 실호출부가 전부 실값을 넘기면: 디폴트를 없앤다(required). 프리뷰·테스트는 `{}` 명시. + * - 상호작용이 진짜 선택적이면: nullable 핸들러(`onXxx: (() -> Unit)?`) + null 이면 UI 숨김, + * 또는 오버로드 분리. `= {}` 로 죽은 버튼을 그리지 않는다. + * + * ### [LEGACY_NO_OP_DEFAULT_FILES] + * 가드 도입 시점(#1388)에 남아 있던 잔여 파일. 모듈 담당별 후속 PR 이 청소하며 목록에서 뺀다. + * 목록에 있는 파일은 위반이 **있어도 없어도 통과**한다(관대 판정) — 파일을 청소하는 PR 과 + * 목록을 갱신하는 PR 의 머지 순서가 develop 을 red 로 만들지 않게 하기 위해서다 + * ([ResponseDtoContractKonsistTest] 의 #933 전례와 같은 구조). 이미 청소된 항목이 목록에 + * 남아 있으면 아래 「해소된 항목은 경고로 알린다」 가 경고만 낸다. + */ +class NoOpCallbackDefaultKonsistTest { + @Test + fun `컴포저블 on 콜백 파라미터는 no-op 디폴트를 두지 않는다`() { + val violations = noOpCallbackDefaults().filterNot { it.isLegacyFile() } + + check(violations.isEmpty()) { + buildString { + appendLine("on* 콜백 파라미터에 no-op 디폴트가 새로 추가됐다 (${violations.size}건).") + appendLine("배선을 빠뜨려도 컴파일이 통과해 조용히 no-op 이 된다 (#582 · #618 · #722 · #777 전례).") + appendLine() + violations.map(NoOpDefault::describe).sorted().forEach { appendLine(" $it") } + appendLine() + appendLine("실호출부가 전부 실값을 넘기면 디폴트를 없애고(프리뷰·테스트는 {} 명시),") + appendLine("진짜 선택적 상호작용이면 nullable 핸들러 + UI 숨김으로 모델링한다.") + appendLine("기준: docs/convention/composable-callback-defaults.md") + } + } + } + + /** + * 해소된 항목이 목록에 남아 있으면 **경고만** 낸다. + * + * 실패시키면 「파일을 청소하는 PR」 과 「목록에서 빼는 PR」 이 서로를 깨뜨린다 — + * [ResponseDtoContractKonsistTest] 의 동명 테스트와 같은 이유다. 신규 유입 차단은 + * 위 테스트가 담당하고, 이쪽은 목록 갱신 시점을 알리는 데 그친다. + */ + @Test + fun `해소된 항목은 경고로 알린다`() { + val violatedPaths = noOpCallbackDefaults().map(NoOpDefault::path) + val stale = + LEGACY_NO_OP_DEFAULT_FILES.filter { suffix -> violatedPaths.none { it.endsWith(suffix) } } + if (stale.isEmpty()) return + + println( + buildString { + appendLine("[경고] LEGACY_NO_OP_DEFAULT_FILES 에 이미 해소된 항목이 남아 있다 (${stale.size}건).") + appendLine("목록에서 지워야 다음 위반이 이 자리에 숨지 않는다.") + appendLine() + stale.sorted().forEach { appendLine(" $it") } + }, + ) + } + + private fun noOpCallbackDefaults(): List = + guardedFiles().flatMap { file -> + val composableFunctionParams = + file + .functions() + .filter { function -> function.annotations.any { it.name == COMPOSABLE } } + .flatMap { function -> function.parameters.noOpCallbacks(file, function.name) } + val constructorParams = + file + .classes() + .flatMap { cls -> + val parameters = cls.primaryConstructor?.parameters.orEmpty() + parameters.noOpCallbacks(file, cls.name) + } + composableFunctionParams + constructorParams + } + + private fun List.noOpCallbacks( + file: KoFileDeclaration, + owner: String, + ): List = + filter { parameter -> CALLBACK_NAME.containsMatchIn(parameter.name) && parameter.hasNoOpDefault() } + .map { parameter -> NoOpDefault(path = file.normalizedProjectPath(), owner = owner, parameter = parameter.name) } + + private fun KoParameterDeclaration.hasNoOpDefault(): Boolean { + val normalized = defaultValue?.replace(WHITESPACE, "") ?: return false + return NO_OP_LAMBDA.matches(normalized) + } + + private fun guardedFiles(): List = + Konsist + .scopeFromProject() + .files + .filter { file -> + val path = file.normalizedProjectPath() + (path.startsWith("app/") || path.startsWith("feature/")) && "/src/main/" in path + } + + private fun KoFileDeclaration.normalizedProjectPath(): String = projectPath.replace('\\', '/').trimStart('/') + + private data class NoOpDefault( + val path: String, + val owner: String, + val parameter: String, + ) { + fun isLegacyFile(): Boolean = LEGACY_NO_OP_DEFAULT_FILES.any { path.endsWith(it) } + + fun describe(): String = "$path — $owner($parameter)" + } + + private companion object { + const val COMPOSABLE = "Composable" + + /** `on` + 대문자로 시작하는 콜백 이름. */ + val CALLBACK_NAME = Regex("""^on[A-Z]""") + + /** 공백 제거 후 `{}` · `{->}` · `{_->}` · `{_,_->}` 꼴 — 아무것도 하지 않는 람다. */ + val NO_OP_LAMBDA = Regex("""^\{(_(,_)*)?(->)?\}$""") + + val WHITESPACE = Regex("""\s""") + + /** afternote — PR #1336 이 같은 파일을 만지는 중이라 #1388 afternote 몫에서 제외한 1건. */ + private val AFTERNOTE = + setOf( + "feature/afternote/presentation/author/detail/MemorialDetailScreen.kt", + ) + + /** home — #1388 모듈 몫 후속 PR 이 청소한다. */ + private val HOME = + setOf( + "feature/home/presentation/WeeklySummaryGrid.kt", + ) + + /** mindrecord — #1388 모듈 몫 후속 PR 이 청소한다. */ + private val MINDRECORD = + setOf( + "feature/mindrecord/presentation/component/DailyQuestionListCard.kt", + "feature/mindrecord/presentation/component/DiaryCard.kt", + "feature/mindrecord/presentation/component/DiaryComponent.kt", + "feature/mindrecord/presentation/component/KeyBoardToolBar.kt", + "feature/mindrecord/presentation/component/MemoriesCard.kt", + "feature/mindrecord/presentation/component/TodayQuestionCard.kt", + "feature/mindrecord/presentation/component/WeeklyReportReviewCard.kt", + "feature/mindrecord/presentation/component/WriteTextField.kt", + "feature/mindrecord/presentation/screen/receiver/ReceiverMindRecordScreen.kt", + "feature/mindrecord/presentation/screen/sender/DailyQuestionAnswerListScreen.kt", + "feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt", + "feature/mindrecord/presentation/screen/sender/DiaryScreen.kt", + "feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt", + "feature/mindrecord/presentation/screen/sender/DraftListScreen.kt", + "feature/mindrecord/presentation/screen/sender/HomeScreen.kt", + ) + + /** receiver — #1388 모듈 몫 후속 PR 이 청소한다. */ + private val RECEIVER = + setOf( + "feature/receiver/presentation/home/ReceiverHomeActions.kt", + "feature/receiver/presentation/summary/ContentSection.kt", + "feature/receiver/presentation/summary/ReceiverAfternoteScreen.kt", + ) + + /** timeletter — #1388 모듈 몫 후속 PR 이 청소한다 (TimeLetterWriteScreen 은 #778 과 조정). */ + private val TIMELETTER = + setOf( + "feature/timeletter/presentation/component/DraftLetterItem.kt", + "feature/timeletter/presentation/component/TimeLetterBlockItem.kt", + "feature/timeletter/presentation/component/TimeLetterContent.kt", + "feature/timeletter/presentation/component/TimeletterListItem.kt", + "feature/timeletter/presentation/screen/recipient/RecipientTimeletterScreen.kt", + "feature/timeletter/presentation/screen/sender/TimeLetterWriteScreen.kt", + "feature/timeletter/presentation/screen/sender/TimeletterScreen.kt", + ) + + val LEGACY_NO_OP_DEFAULT_FILES = AFTERNOTE + HOME + MINDRECORD + RECEIVER + TIMELETTER + } +}