diff --git a/docs/qa/assumptions.md b/docs/qa/assumptions.md index 5e549a0484..5355c45a8f 100644 --- a/docs/qa/assumptions.md +++ b/docs/qa/assumptions.md @@ -267,17 +267,29 @@ Compose 테스트로 고정했다. 스키마에 `date` 없음, 보내도 무시됨을 실측). 날짜 선택이 표시 전용인 문제는 BE 계약 확장이나 기획 결정이 필요해 #1008 로 분리했다. -## #1008 — 일기 기록 날짜를 표시 전용으로 (판단 근거) +## #1008 — 일기 기록 날짜 (해소, 추측 없음) -서버가 `date` 를 받지 않는다는 것은 추측이 아니라 실측이다 (2026-08-25). +**서버가 `date` 를 받는다.** 2026-08-25 실측 당시에는 아니었고, 그래서 #1121 이 날짜 행을 +표시 전용으로 잠갔다. 그 뒤 BE 가 (a) 계약 확장을 했다. -- `POST /api/v1/diary` 에 `date: "2026-08-01"` → 저장분 `date: "2026-08-25"` (diaryId 41545) -- `PATCH /api/v1/diary/41545` 에 같은 값 → 저장분 그대로 `2026-08-25` -- Swagger 스키마의 요청 필드: `title · content · isDraft · todayMood · receiverIds` (양쪽 모두 `date` 없음) +| 시점 | 상태 | +|---|---| +| 2026-08-25 (FE 실측) | `POST`·`PATCH` 에 `date: "2026-08-01"` 을 실어도 저장분은 `2026-08-25`. Swagger 요청 스키마에 `date` 없음 | +| 2026-08-29 (`Afternote-BE#244` → PR #262 머지) | `DiaryCreateRequest.date`·`DiaryUpdateRequest.date` 신설. `Diary.entry_date` 컬럼과 `(user_id, entry_date)` 인덱스, 월 조회·주간 리포트가 `entry_date` 기준 | + +계약 규칙도 서버 코드에서 그대로 읽었다. + +- 생성: 미전송이면 오늘(Asia/Seoul) +- 수정: **생략이면 기존 값 유지** +- 공통: 미래 날짜는 `400`(code `2101`), 과거는 제한 없음 + +FE 는 그 계약대로 되돌렸다 — 날짜 행에 피커를 다시 붙이고, 고른 값을 요청에 싣는다. +수정 경로는 «화면 값이 서버에서 왔거나 사용자가 고른 것일 때만» 싣는다. 프리필이 실패한 채 +오늘을 실어 보내면 기존 기록일이 조용히 오늘로 옮겨지기 때문이다 — 수신자 목록을 빈 목록으로 +보내면 안 되는 것과 같은 함정이다. `DiaryRecordDateWireTest` 가 다섯 갈래를 잠근다. -**추측인 부분은 «어느 쪽으로 맞출지» 다.** 이슈가 든 두 선택지 중 (b) 표시 전용을 택했다 — -(a) BE 확장은 우리 손 밖이고, 그때까지 «고를 수 있지만 반영되지 않는» 상태를 두면 사용자가 -고른 날짜로 저장됐다고 믿는다. BE 가 `date` 를 수용하면 피커를 되돌린다. +**남은 것은 없다.** 시안(Figma 2671:17922)의 chevron 도 이제 코드와 같다 — #1121 이 그것을 +뺐던 것이 의도적 이탈이었고, 그 이탈이 해소됐다. ## #613 — 수신자 홈 설정 진입 (판단 보류) - 시안(수신자 섹션 `4327:73626`)의 수신자 홈 헤더에는 **톱니만** 있고 프로필 아이콘이 없다. diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt index c58609710e..ebe936e1ba 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/DiaryDto.kt @@ -30,6 +30,12 @@ data class DiaryCreateRequestDto( // 생성 API 에서 `null` 과 빈 목록은 모두 "수신자 없음" 으로 정규화되고, 작성 UI 는 항상 // 목록을 갖고 있다. 빈 목록을 그대로 보내면 되므로 nullable 로 낮추지 않는다 (#789). @SerialName("receiverIds") val receiverIds: List, + /** + * 기록일 `yyyy-MM-dd`. 미전송이면 서버가 오늘(Asia/Seoul)로 채우고, **미래 날짜는 + * 400(code 2101)** 이다 (`Afternote-BE#244`, PR #262). 작성 화면이 항상 값을 갖고 + * 있어 nullable 로 낮추지 않는다 (#1008). + */ + @SerialName("date") val date: String, ) @Serializable @@ -40,6 +46,8 @@ data class DiaryUpdateRequestDto( @SerialName("todayMood") val todayMood: TodayMoodDto, /** null 이면 기존 수신자 유지, 빈 목록이면 전체 해제 (서버 규칙). */ @SerialName("receiverIds") val receiverIds: List? = null, + /** 기록일 `yyyy-MM-dd`. **null 이면 기존 기록일 유지** (서버 규칙). 미래 날짜는 400(code 2101). */ + @SerialName("date") val date: String? = null, ) /** diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt index a5aaad9aa3..c3d64c6d7e 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/DiaryMapper.kt @@ -52,6 +52,8 @@ fun DiaryCreatePayload.toRequest(): DiaryCreateRequestDto = isDraft = isDraft, todayMood = todayMood.toDto(), receiverIds = receiverIds, + // LocalDate.toString() 이 ISO-8601 `yyyy-MM-dd` 다 — 서버 계약과 같은 형식이다. + date = date.toString(), ) fun DiaryUpdatePayload.toRequest(): DiaryUpdateRequestDto = @@ -61,4 +63,5 @@ fun DiaryUpdatePayload.toRequest(): DiaryUpdateRequestDto = isDraft = isDraft, todayMood = todayMood.toDto(), receiverIds = receiverIds, + date = date?.toString(), ) diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt index 0d3bcde9f0..1235549701 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/MindRecordDtoContractTest.kt @@ -122,12 +122,46 @@ class MindRecordDtoContractTest { isDraft = false, todayMood = TodayMoodDto.SOSO, receiverIds = emptyList(), + date = "2026-08-01", ), ) assertEquals(true, encoded.contains("\"receiverIds\":[]")) } + @Test + fun `생성은 기록일을 항상 싣고 수정은 생략할 수 있다`() { + // 서버는 생성에서 미전송이면 오늘로 채우고, 수정에서 생략이면 기존 값을 유지한다 + // (Afternote-BE#244, PR #262). 그 차이가 타입에 그대로 드러나야 한다 (#1008). + val created = + json.encodeToString( + DiaryCreateRequestDto.serializer(), + DiaryCreateRequestDto( + title = "t", + content = "c", + isDraft = false, + todayMood = TodayMoodDto.SOSO, + receiverIds = emptyList(), + date = "2026-08-01", + ), + ) + assertEquals(true, created.contains("\"date\":\"2026-08-01\"")) + + val omitted = + json.encodeToString( + DiaryUpdateRequestDto.serializer(), + DiaryUpdateRequestDto( + title = "t", + content = "c", + isDraft = false, + todayMood = TodayMoodDto.SOSO, + receiverIds = null, + date = null, + ), + ) + assertEquals(false, omitted.contains("\"date\"")) + } + @Test fun `PATCH 는 생략과 명시적 null 이 코드에서 구분된다`() { // 선택 필드라 nullable 은 유지하되 기본값은 없다 — 호출부가 모든 인자를 적어야 한다. diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt index dea157622d..d0b0cf14c9 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/DiaryModels.kt @@ -1,5 +1,7 @@ package com.afternote.feature.mindrecord.domain.model +import java.time.LocalDate + enum class TodayMood { HAPPY, SOSO, @@ -38,6 +40,13 @@ data class DiaryCreatePayload( val todayMood: TodayMood, /** 이 일기를 전달받을 수신자 ID 목록. 미선택 시 빈 목록 — 서버가 "수신자 없음" 으로 본다. */ val receiverIds: List, + /** + * 사용자가 고른 기록일. 서버는 미전송 시 오늘(Asia/Seoul)로 채우지만 작성 화면은 항상 + * 값을 갖고 있어 그대로 싣는다 (#1008). + * + * **미래 날짜는 서버가 400(code 2101)으로 거절한다.** 과거는 제한이 없다. + */ + val date: LocalDate, ) data class DiaryUpdatePayload( @@ -53,4 +62,12 @@ data class DiaryUpdatePayload( * 기존 지정을 지워 버린다. */ val receiverIds: List? = null, + /** + * 기록일. **`null` 이면 서버가 기존 값을 유지한다** (`DiaryUpdateRequest.date` — 생략 시 유지). + * + * 화면에 뜬 날짜가 서버에서 온 것이거나 사용자가 직접 고른 것일 때만 싣는다. 프리필이 + * 날짜를 못 가져온 채로 «오늘» 을 실어 보내면 기록일이 조용히 오늘로 옮겨진다 — 수신자 + * 목록을 빈 목록으로 보내면 안 되는 것과 같은 종류의 함정이다 (#1008). + */ + val date: LocalDate? = null, ) diff --git a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt index 6814dd9c85..69c4b19024 100644 --- a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt +++ b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt @@ -69,12 +69,15 @@ class FakeDiaryRepository( onUpdate?.let { return it(id, payload) } diaries.replaceAll { diary -> if (diary.diaryId == id) { - // date·imageUrl 은 수정 요청 계약에서 빠졌다 (#955) — 기존 값을 유지한다. + // imageUrl 은 수정 요청 계약에 없다 (#955) — 기존 값을 유지한다. + // date 는 2026-08-29 부터 계약에 있고 **생략(null)이면 기존 값 유지**다 + // (Afternote-BE#244, PR #262). 서버와 같은 규칙으로 흉내 낸다 (#1008). diary.copy( title = payload.title, content = payload.content, todayMood = payload.todayMood, isDraft = payload.isDraft, + date = payload.date?.toString() ?: diary.date, ) } else { diary diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt index 603ad6c89c..74a5475691 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt @@ -47,6 +47,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.R import com.afternote.core.ui.asString +import com.afternote.core.ui.calendar.BottomSheetCalendar import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.core.ui.topbar.DetailTopBar @@ -71,6 +72,7 @@ fun DiaryWriteScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var showReceiverSheet by remember { mutableStateOf(false) } + var showDateSheet by remember { mutableStateOf(false) } val currentOnSubmitSuccess by rememberUpdatedState(onSubmitSuccess) LaunchedEffect(uiState.submitState) { @@ -91,6 +93,7 @@ fun DiaryWriteScreen( onContentChanged = viewModel::onContentChanged, onMoodSelected = viewModel::onMoodSelected, onReceiverRowClick = { showReceiverSheet = true }, + onDateRowClick = { showDateSheet = true }, onMediaPicked = viewModel::uploadMedia, ) @@ -106,6 +109,20 @@ fun DiaryWriteScreen( onDismiss = { showReceiverSheet = false }, ) } + + if (showDateSheet) { + BottomSheetCalendar( + title = stringResource(MindRecordR.string.mindrecord_write_date_picker_title), + initialDate = uiState.date, + // 미래 날짜는 서버가 400(code 2101)으로 거절한다. 시트는 core:ui 공용이라 + // 달력에서 막지 못하므로 ViewModel 이 받아서 사유와 함께 되돌린다 (#1008). + onDateSelect = { + viewModel.onDateSelected(it) + showDateSheet = false + }, + onDismiss = { showDateSheet = false }, + ) + } } /** @@ -131,6 +148,7 @@ internal fun DiaryWriteScreenContent( onContentChanged: (String) -> Unit, onMoodSelected: (TodayMood) -> Unit, onReceiverRowClick: () -> Unit, + onDateRowClick: () -> Unit, onMediaPicked: suspend (String) -> String? = { null }, ) { Scaffold( @@ -243,15 +261,15 @@ internal fun DiaryWriteScreenContent( } // Figma 2671:17922 — 날짜 선택 행 ("yyyy년 M월 d일" + 아래 화살표) - // **표시 전용이다.** 서버는 생성·수정 어느 쪽에서도 date 를 받지 않고 기록 날짜를 - // 요청 시각으로 정한다 (실측 2026-08-25: POST·PATCH 에 date 를 실어도 저장분은 - // 오늘, Swagger 스키마에도 필드가 없다). 고를 수 있게 두면 사용자는 고른 날짜로 - // 남았다고 믿는데 실제로는 오늘로 저장된다 — 고르지 못하게 해 거짓을 없앤다 (#1008). // - // BE 가 date 를 수용하면 이 자리에 피커를 되돌린다. 이어쓰기 프리필이 채워 주는 - // 원래 날짜를 보여 주는 역할은 그대로다. + // 서버가 2026-08-29 부터 생성·수정 양쪽에서 `date` 를 받는다 (Afternote-BE#244, PR #262). + // 그 전까지는 고른 날짜가 요청에 실리지 않아 «고를 수 있지만 반영되지 않는» 상태였고, + // #1121 이 그 동안 이 행을 표시 전용으로 잠가 뒀다. 계약이 왔으므로 시안대로 되돌린다 (#1008). Row( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .clickable(role = Role.Button, onClick = onDateRowClick), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -260,6 +278,12 @@ internal fun DiaryWriteScreenContent( style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray9, ) + Icon( + painter = painterResource(R.drawable.core_ui_arrowdown), + contentDescription = null, + tint = AfternoteDesign.colors.gray9, + modifier = Modifier.size(16.dp), + ) } HorizontalDivider() @@ -330,6 +354,7 @@ internal fun DiaryWriteScreenContent( val errorMessage = (uiState.submitState as? SubmitState.Failed)?.message?.asString() + ?: uiState.dateError?.asString() ?: uiState.draftLoadError?.asString() if (errorMessage != null) { Text( diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt index 3a181136cb..65c8bc0961 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteUiState.kt @@ -12,11 +12,23 @@ data class DiaryWriteUiState( val content: String = "", val mood: TodayMood? = null, /** - * 화면에 표시할 기록 날짜. **표시 전용이다** — 서버는 생성·수정 어느 쪽에서도 이 값을 - * 받지 않고 기록 날짜를 요청 시각으로 정한다 (#1008). 이어쓰기 프리필이 원래 날짜를 - * 채워 주는 자리라 상태 자체는 남긴다. + * 화면에 표시하고 서버로 보낼 기록일. 사용자가 날짜 행에서 고른다 (#1008). + * + * 서버는 2026-08-29 부터 생성·수정 양쪽에서 `date` 를 받는다 (`Afternote-BE#244`, PR #262). + * 미래 날짜는 400(code 2101)이라 [dateError] 로 미리 막는다. */ val date: LocalDate = LocalDate.now(), + /** + * [date] 를 **수정 요청에 실어도 되는지.** 프리필이 서버 날짜를 준 뒤이거나 사용자가 직접 + * 고른 뒤에만 true 다. + * + * 신규 작성은 언제나 싣는다(기본값이 오늘이고 그대로 저장되는 것이 맞다). 문제는 수정 + * 경로다 — 프리필이 실패한 채 «오늘» 을 실어 보내면 기존 기록일이 조용히 오늘로 옮겨진다. + * 그럴 땐 키를 생략해 서버가 기존 값을 유지하게 한다 (#1008). + */ + val isDateChosen: Boolean = false, + /** 고를 수 없는 날짜를 짚었을 때의 안내. 서버가 400 을 주기 전에 화면에서 먼저 막는다 (#1008). */ + val dateError: UiText? = null, /** `GET /users/receivers` 로 불러온 내 수신인 목록. */ val receivers: List = emptyList(), /** diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt index b375c518f8..952f965f39 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt @@ -86,6 +86,22 @@ class DiaryWriteViewModel _uiState.update { it.copy(content = value) } } + /** + * 날짜 행에서 고른 기록일을 싣는다 (#1008). + * + * **미래 날짜는 받지 않는다.** 서버가 400(code 2101)으로 거절하므로, 상태에 넣으면 + * 사용자는 저장을 눌러 봐야 실패를 안다. 고르는 순간 사유와 함께 막는다. + */ + fun onDateSelected(date: LocalDate) { + if (date.isAfter(LocalDate.now())) { + _uiState.update { + it.copy(dateError = UiText.Resource(R.string.mindrecord_error_diary_future_date)) + } + return + } + _uiState.update { it.copy(date = date, isDateChosen = true, dateError = null) } + } + fun onMoodSelected(mood: TodayMood) { _uiState.update { it.copy(mood = mood) } } @@ -186,6 +202,9 @@ class DiaryWriteViewModel // 전체 해제로 읽어, 수신자를 건드리지 않은 편집이 기존 지정을 // 지운다 — 목록 응답에 수신자가 없어 되살릴 수도 없다. receiverIds = state.selectedReceiverIds.toList().takeIf { it.isNotEmpty() }, + // 화면 값이 서버에서 왔거나 사용자가 고른 것일 때만 싣는다. + // 그 밖에는 키를 생략해 서버가 기존 기록일을 유지한다 (#1008). + date = state.date.takeIf { state.isDateChosen }, ), ) } else { @@ -196,6 +215,7 @@ class DiaryWriteViewModel isDraft = isDraft, todayMood = mood, receiverIds = state.selectedReceiverIds.toList(), + date = state.date, ), ) } @@ -288,9 +308,11 @@ class DiaryWriteViewModel title = draft.title, content = draft.content, mood = draft.todayMood, - // 표시 전용 값이다 — 서버가 날짜를 주면 그걸 보여 주고, 안 주면 - // 화면에 이미 떠 있던 값을 유지한다. 고르는 수단은 없다 (#1008). + // 서버가 준 기록일을 그대로 보여 주고, 그때부터 수정 요청에도 싣는다. + // 프리필이 날짜를 못 주면 화면 값(오늘)을 유지하되 `isDateChosen` 은 + // false 로 남겨, 수정이 기존 기록일을 오늘로 밀지 않게 한다 (#1008). date = draft.toUi()?.date ?: it.date, + isDateChosen = draft.toUi()?.date != null || it.isDateChosen, isDraftLoading = false, draftLoaded = true, ) diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index aa7f2019ea..6b84cc5be2 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -54,6 +54,7 @@ 보통 슬픔 수신자 설정하기 + 기록 날짜 %s님에게 %1$s님 외 %2$d명에게 수신자 선택 @@ -203,6 +204,7 @@ 일기를 불러오지 못했습니다. 일기 등록에 실패했습니다. 임시 저장된 일기를 불러오지 못했습니다. + 기록 날짜는 오늘까지만 고를 수 있습니다. 임시저장한 답변을 불러오지 못했어요. 이대로 저장하면 이전 임시저장이 덮어써집니다. 등록에 실패했습니다. 답변을 입력해 주세요. diff --git a/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTest.kt b/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTest.kt index ed96c843fb..fec5ecb775 100644 --- a/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTest.kt +++ b/feature/mindrecord/presentation/src/screenshotTest/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTest.kt @@ -30,6 +30,7 @@ internal fun diaryWriteScreenScreenshot() { onDraftListClick = {}, onMoodSelected = {}, onReceiverRowClick = {}, + onDateRowClick = {}, onSaveDraft = {}, onSubmit = {}, onTitleChanged = {}, @@ -50,6 +51,7 @@ internal fun diaryWriteScreenCompactScreenshot() { onDraftListClick = {}, onMoodSelected = {}, onReceiverRowClick = {}, + onDateRowClick = {}, onSaveDraft = {}, onSubmit = {}, onTitleChanged = {}, diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenCompactScreenshot_e7ca7c28_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenCompactScreenshot_e7ca7c28_0.png index e9512c5ee7..bfdbe98ce8 100644 Binary files a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenCompactScreenshot_e7ca7c28_0.png and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenCompactScreenshot_e7ca7c28_0.png differ diff --git a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenScreenshot_90705b76_0.png b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenScreenshot_90705b76_0.png index 3866217858..9f6fb0a55f 100644 Binary files a/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenScreenshot_90705b76_0.png and b/feature/mindrecord/presentation/src/screenshotTestDebug/reference/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreenScreenshotTestKt/diaryWriteScreenScreenshot_90705b76_0.png differ diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/a11y/WriteToolbarAccessibilityTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/a11y/WriteToolbarAccessibilityTest.kt index e4b674ea58..84ebb0a4af 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/a11y/WriteToolbarAccessibilityTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/a11y/WriteToolbarAccessibilityTest.kt @@ -337,6 +337,7 @@ class WriteToolbarAccessibilityTest { onContentChanged = {}, onMoodSelected = {}, onReceiverRowClick = {}, + onDateRowClick = {}, ) } } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/DiaryWriteDateDisplayTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/DiaryWriteDateDisplayTest.kt deleted file mode 100644 index 0d1528bf16..0000000000 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/DiaryWriteDateDisplayTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.afternote.feature.mindrecord.presentation.screen - -import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewModel -import org.junit.Assert.assertTrue -import org.junit.Test -import java.time.LocalDate - -/** - * 일기 기록 날짜가 «고를 수 있지만 반영되지 않는» 상태가 아닌지 (#1008). - * - * 서버는 생성·수정 어느 쪽에서도 `date` 를 받지 않고 기록 날짜를 요청 시각으로 정한다 - * (실측 2026-08-25: `POST`·`PATCH` 에 `date: "2026-08-01"` 을 실어 보내도 저장분은 - * `2026-08-25`, Swagger 스키마에도 필드가 없다). 그 상태에서 날짜 피커를 열어 두면 - * 사용자는 고른 날짜로 남았다고 믿는다. - * - * 화면에서 피커를 걷어냈으므로 **ViewModel 에 날짜를 바꾸는 창구가 남아 있으면 안 된다** — - * 남겨 두면 다음 사람이 그 함수를 보고 UI 를 되살린다. BE 가 `date` 를 수용하면 그때 함께 - * 되돌린다. - */ -class DiaryWriteDateDisplayTest { - @Test - fun `날짜를 바꾸는 공개 창구가 없다`() { - // "update" 가 "date" 를 부분 문자열로 품는다 — 날짜와 무관한 updateXxx 가 하나만 - // 생겨도 빨개지므로, 이름 규칙이 아니라 **LocalDate 를 받는 공개 함수**로 좁힌다. - val dateSetters = - DiaryWriteViewModel::class.java.methods - .filter { - java.lang.reflect.Modifier - .isPublic(it.modifiers) - }.filter { it.parameterTypes.any { type -> type == LocalDate::class.java } } - .map { it.name } - - assertTrue("날짜 변경 창구가 남아 있다: $dateSetters", dateSetters.isEmpty()) - } -} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryRecordDateWireTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryRecordDateWireTest.kt new file mode 100644 index 0000000000..33fa4466a0 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryRecordDateWireTest.kt @@ -0,0 +1,193 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.repository.PhotoUploadRepository +import com.afternote.core.domain.repository.UserReceiverRepository +import com.afternote.feature.mindrecord.domain.model.Diary +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException +import java.lang.reflect.Proxy +import java.time.LocalDate + +/** + * 사용자가 고른 기록일이 실제로 요청에 실리는지 (#1008). + * + * 종전에는 날짜 선택이 **표시 전용**이었다 — 서버 스키마에 `date` 가 없어 고른 값이 요청에 + * 실리지 않았고, #1121 이 「고를 수 있지만 반영되지 않는」 상태를 없애려 피커를 걷었다. + * 서버가 2026-08-29 부터 생성·수정 양쪽에서 `date` 를 받으므로(`Afternote-BE#244`, PR #262) + * 피커를 되돌렸고, 이 가드가 그 배선을 잠근다. + * + * 이 파일은 `DiaryWriteDateDisplayTest`(「날짜를 바꾸는 공개 창구가 없다」)를 대체한다 — + * 그 단언은 이제 정반대다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +// 수정 진입 시나리오가 `SavedStateHandle.toRoute()` 로 Bundle 을 탄다 — 순수 JVM 에서는 +// `BaseBundle.putLong` 이 not-mocked 로 죽는다. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DiaryRecordDateWireTest { + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `신규 작성은 고른 날짜를 그대로 싣는다`() = + runTest(dispatcher) { + val repository = FakeDiaryRepository() + val viewModel = diaryViewModel(repository, SavedStateHandle(emptyMap())) + advanceUntilIdle() + + val chosen = LocalDate.now().minusDays(3) + viewModel.onDateSelected(chosen) + fillRequiredFields(viewModel) + viewModel.submit() + advanceUntilIdle() + + assertEquals(chosen, repository.createdPayloads.single().date) + } + + @Test + fun `고르지 않으면 오늘이 실린다`() = + runTest(dispatcher) { + val repository = FakeDiaryRepository() + val viewModel = diaryViewModel(repository, SavedStateHandle(emptyMap())) + advanceUntilIdle() + + fillRequiredFields(viewModel) + viewModel.submit() + advanceUntilIdle() + + assertEquals(LocalDate.now(), repository.createdPayloads.single().date) + } + + @Test + fun `미래 날짜는 상태에 들어가지 않는다`() = + runTest(dispatcher) { + // 서버가 400(code 2101)으로 거절하는 값이다. 상태에 넣으면 사용자는 저장을 + // 눌러 봐야 실패를 안다 — 고르는 순간 사유와 함께 막는다. + val repository = FakeDiaryRepository() + val viewModel = diaryViewModel(repository, SavedStateHandle(emptyMap())) + advanceUntilIdle() + + val today = viewModel.uiState.value.date + viewModel.onDateSelected(LocalDate.now().plusDays(1)) + + assertEquals(today, viewModel.uiState.value.date) + assertEquals(false, viewModel.uiState.value.isDateChosen) + assertEquals(true, viewModel.uiState.value.dateError != null) + } + + @Test + fun `프리필이 날짜를 못 주면 수정은 기록일을 싣지 않는다`() = + runTest(dispatcher) { + // 여기서 «오늘» 을 실어 보내면 기존 기록일이 조용히 오늘로 옮겨진다. 서버는 + // 키를 생략하면 기존 값을 유지하므로 생략이 맞다. + val repository = FakeDiaryRepository() + repository.onGetList = { _, _ -> Result.failure(IllegalStateException("offline")) } + val viewModel = diaryViewModel(repository, editRoute()) + advanceUntilIdle() + + fillRequiredFields(viewModel) + viewModel.submit() + advanceUntilIdle() + + assertNull( + repository.updatedPayloads + .single() + .second.date, + ) + } + + @Test + fun `프리필이 준 날짜는 수정 요청에 그대로 남는다`() = + runTest(dispatcher) { + val repository = FakeDiaryRepository(initialDiaries = listOf(existingDiary(date = "2026-07-11"))) + val viewModel = diaryViewModel(repository, editRoute()) + advanceUntilIdle() + + viewModel.submit() + advanceUntilIdle() + + assertEquals( + LocalDate.of(2026, 7, 11), + repository.updatedPayloads + .single() + .second.date, + ) + } + + private fun fillRequiredFields(viewModel: DiaryWriteViewModel) { + viewModel.onTitleChanged("제목") + viewModel.onContentChanged("

본문

") + viewModel.onMoodSelected(TodayMood.HAPPY) + } + + private fun editRoute() = + SavedStateHandle( + mapOf("recordId" to EXISTING_ID, "yearMonth" to "2026-07", "isDraft" to false), + ) + + private fun existingDiary(date: String) = + Diary( + diaryId = EXISTING_ID, + title = "기존 제목", + content = "

기존 본문

", + date = date, + createdAt = "2026-07-11T09:00:00", + todayMood = TodayMood.SOSO, + isDraft = false, + ) + + private fun diaryViewModel( + repository: FakeDiaryRepository, + savedStateHandle: SavedStateHandle, + ): DiaryWriteViewModel = + DiaryWriteViewModel( + savedStateHandle = savedStateHandle, + repository = repository, + // 이 시나리오는 업로드를 타지 않는다 — 부르면 그 자체가 결함이라 실패로 둔다. + photoUploadRepository = PhotoUploadRepository { _, _ -> Result.failure(IOException("unused")) }, + userRepository = emptyReceiverRepository(), + draftLoader = LoadMindRecordDraftsUseCase(repository, FakeDailyQuestionRepository()), + errorReporter = RecordingErrorReporter(), + ) + + /** UserRepository 는 표면이 넓다 — 이 시나리오가 타는 호출만 답한다. */ + private fun emptyReceiverRepository(): UserReceiverRepository = + Proxy.newProxyInstance( + UserReceiverRepository::class.java.classLoader, + arrayOf(UserReceiverRepository::class.java), + ) { _, method, _ -> + when (method.name) { + "getReceivers" -> emptyList() + "getReceiverListFlow" -> flowOf(emptyList()) + else -> error("Unexpected call: ${method.name}") + } + } as UserReceiverRepository + + private companion object { + const val EXISTING_ID = 4242L + } +}