From 551058386f4b2bd90e3e8827130a14d26226c292 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sun, 23 Aug 2026 14:37:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(mindrecord):=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20=EC=98=A4?= =?UTF-8?q?=EB=8A=98=20=EB=82=A0=EC=A7=9C=EB=A1=9C=20=EB=8D=AE=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parseLocalDateOrNull(raw) ?: LocalDate.now()` 라, 서버가 형식을 바꾸면 지난 주 답변이 오늘 날짜로 표시됐다. 네트워크는 성공했고 값만 못 읽은 상황인데 화면에는 틀린 날짜가 정상처럼 앉고, 로그도 남지 않아 제보를 받아도 재현 지점을 찾을 수 없다. 같은 파일의 `Diary.toUi()` 가 이미 쓰는 규칙을 따른다 — 날짜를 못 정하면 `null` 을 돌리고 호출부가 그 항목을 뺀다. 그 KDoc 이 "오늘 날짜로 폴백하지 않는다" 를 이미 적어 두었는데 데일리질문 경로만 규약을 어기고 있었다. 실패 지점에 `Log.w` 를 남겨 추적할 수 있게 한다. 두 곳을 함께 고친다 — `MindRecordUiMapper.DailyQuestionDomain.toUi()`(목록·임시저장)와 `WeeklyReportViewModel.WeeklyReportDailyQuestion.toUi()`(HISTORY 카드). Closes #751 Co-Authored-By: Claude Opus 5 (1M context) --- .../mindrecord/presentation/build.gradle.kts | 5 ++ .../presentation/mapper/MindRecordUiMapper.kt | 24 +++++-- .../viewmodel/DailyQuestionListViewModel.kt | 3 +- .../viewmodel/DraftListViewModel.kt | 5 +- .../viewmodel/WeeklyReportViewModel.kt | 25 ++++++-- .../mapper/DailyQuestionDateFallbackTest.kt | 63 +++++++++++++++++++ 6 files changed, 111 insertions(+), 14 deletions(-) create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/DailyQuestionDateFallbackTest.kt diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 76d0c5e121..d61968f63b 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -6,6 +6,8 @@ plugins { android { namespace = "com.afternote.feature.mindrecord.presentation" + + testOptions.unitTests.isIncludeAndroidResources = true resourcePrefix = "mindrecord_" } @@ -19,4 +21,7 @@ dependencies { implementation(libs.compose.rich.editor) testImplementation(libs.coroutines.test) + + // 매퍼가 android.util.Log 를 타 JVM 단위 테스트로는 돌지 않는다 (#751). + testImplementation(libs.robolectric) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt index 33c461f8d9..5a1901edc8 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/mapper/MindRecordUiMapper.kt @@ -1,5 +1,6 @@ package com.afternote.feature.mindrecord.presentation.mapper +import android.util.Log import com.afternote.feature.mindrecord.domain.model.Diary import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.presentation.model.DailyDiary @@ -19,14 +20,27 @@ private val DateFormatters: List = DateTimeFormatter.ISO_DATE_TIME, ) -fun DailyQuestionDomain.toUi(): DailyQuestion = - DailyQuestion( +/** + * 날짜를 못 정하면 `null` 을 돌린다 — 호출부가 그 항목을 목록에서 뺀다. + * + * [Diary.toUi] 와 같은 규칙이다. 오늘 날짜로 메우면 **틀린 날짜가 정상처럼 표시되고** + * 실패 신호가 어디에도 남지 않아, "날짜가 이상하다" 는 제보를 받아도 재현 지점을 찾을 수 + * 없다 (#751). + */ +fun DailyQuestionDomain.toUi(): DailyQuestion? { + val resolvedDate = + parseLocalDateOrNull(createdAt) ?: run { + Log.w(TAG, "데일리질문 날짜를 해석하지 못해 목록에서 제외한다: id=$dailyQuestionId") + return null + } + return DailyQuestion( id = dailyQuestionId, title = title, - date = parseLocalDate(createdAt), + date = resolvedDate, content = content, imageUrl = imageUrl, ) +} /** * 날짜를 못 정하면 `null` 을 돌린다 — 호출부가 그 항목을 목록에서 뺀다. @@ -57,8 +71,6 @@ fun TodayMood.toEmoji(): String = TodayMood.SAD -> "😢" } -private fun parseLocalDate(raw: String): LocalDate = parseLocalDateOrNull(raw) ?: LocalDate.now() - private fun parseLocalDateOrNull(raw: String?): LocalDate? { // "2026.05.22 금" 처럼 뒤에 요일이 붙어오는 케이스 대응 — 첫 공백 앞부분만 파싱. val datePart = raw?.substringBefore(' ')?.trim().orEmpty() @@ -68,3 +80,5 @@ private fun parseLocalDateOrNull(raw: String?): LocalDate? { } return null } + +private const val TAG = "MindRecordUiMapper" diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt index 9617e2bc10..ae430bbe7f 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt @@ -136,7 +136,8 @@ class DailyQuestionListViewModel isAnswered = it.isAnswered, ) }, - answers = phase.answers.map { it.toUi() }, + // 날짜를 못 정한 항목은 toUi() 가 null 을 돌린다 — 정렬 키가 없어 카드로 만들지 않는다 (#751). + answers = phase.answers.mapNotNull { it.toUi() }, ) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt index 6f5c50e3fb..96fbb4edf6 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt @@ -136,8 +136,9 @@ class DraftListViewModel ) } val dailyQuestionItems = - draftDailyQuestionsDeferred.await().map { question -> - val ui = question.toUi() + // 날짜를 못 정한 항목은 toUi() 가 null 을 돌린다 — 정렬 키가 없어 뺀다 (#751). + draftDailyQuestionsDeferred.await().mapNotNull { question -> + val ui = question.toUi() ?: return@mapNotNull null DraftItem( id = ui.id, category = DraftCategory.DailyQuestion, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt index ae05e405cc..947480d3be 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt @@ -1,5 +1,6 @@ package com.afternote.feature.mindrecord.presentation.viewmodel +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.domain.repository.UserRepository @@ -223,7 +224,7 @@ class WeeklyReportViewModel weekDays = mapWeekDays(monday, report.week), emotionKeywords = mapEmotionKeywords(report.emotions), summaryText = report.summaryText, - dailyQuestions = report.dailyQuestions.map { it.toUi() }, + dailyQuestions = report.dailyQuestions.mapNotNull { it.toUi() }, ) } @@ -242,6 +243,8 @@ class WeeklyReportViewModel // 카드 측에서 키워드 개수(0~4)에 따라 슬롯(size·offset·color)을 결정하므로, // 여기선 percentage 내림차순으로 정렬해 최대 4건만 잘라 키워드·카운트만 노출한다. + private const val TAG = "WeeklyReportViewModel" + private const val MAX_EMOTION_KEYWORDS = 4 private fun mapEmotionKeywords(emotions: List): List = @@ -250,12 +253,24 @@ class WeeklyReportViewModel .take(MAX_EMOTION_KEYWORDS) .map { EmotionKeyword(keyword = it.keyword, count = it.percentage) } - private fun WeeklyReportDailyQuestion.toUi(): DailyQuestion = - DailyQuestion( + /** + * 날짜를 못 정하면 `null` 을 돌린다 — 호출부가 그 항목을 목록에서 뺀다. + * + * 오늘 날짜로 메우면 지난 주 답변이 오늘로 표시되고 실패 신호가 남지 않는다 (#751). + * 서버 명세에 `date` 형식이 정의돼 있지 않아 언제든 어긋날 수 있다. + */ + private fun WeeklyReportDailyQuestion.toUi(): DailyQuestion? { + val resolvedDate = + parseLocalDateOrNull(date) ?: run { + Log.w(TAG, "주간리포트 데일리질문 날짜를 해석하지 못해 목록에서 제외한다: raw=$date") + return null + } + return DailyQuestion( title = title, - date = parseLocalDate(date), + date = resolvedDate, content = content, ) + } // 서버는 "yyyy.MM.dd 요일" 또는 ISO 포맷으로 내려옴 — 둘 다 허용. private val DATE_FORMATTERS: List = @@ -264,8 +279,6 @@ class WeeklyReportViewModel DateTimeFormatter.ISO_DATE, ) - private fun parseLocalDate(raw: String): LocalDate = parseLocalDateOrNull(raw) ?: LocalDate.now() - private fun parseLocalDateOrNull(raw: String): LocalDate? { val datePart = raw.substringBefore(' ').trim() for (formatter in DATE_FORMATTERS) { diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/DailyQuestionDateFallbackTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/DailyQuestionDateFallbackTest.kt new file mode 100644 index 0000000000..e5f8f5d6b6 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/mapper/DailyQuestionDateFallbackTest.kt @@ -0,0 +1,63 @@ +package com.afternote.feature.mindrecord.presentation.mapper + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate +import com.afternote.feature.mindrecord.domain.model.DailyQuestion as DailyQuestionDomain + +/** + * 날짜 파싱 실패가 **오늘 날짜로 둔갑하지 않는지** 고정한다 (#751). + * + * 종전에는 `parseLocalDateOrNull(raw) ?: LocalDate.now()` 라, 서버가 형식을 바꾸면 지난 주 + * 답변이 오늘 날짜로 표시되고 실패 신호가 어디에도 남지 않았다. "날짜가 이상하다" 는 제보를 + * 받아도 재현 지점을 찾을 수 없는 종류다. + * + * `Log.w` 를 타므로 Robolectric 으로 돌린다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DailyQuestionDateFallbackTest { + @Test + fun `날짜를 해석할 수 없으면 오늘로 메우지 않고 항목을 버린다`() { + val ui = dailyQuestion(createdAt = "언젠가").toUi() + + assertNull(ui) + } + + @Test + fun `빈 날짜도 오늘로 메우지 않는다`() { + val ui = dailyQuestion(createdAt = "").toUi() + + assertNull(ui) + } + + @Test + fun `요일이 붙은 서버 형식은 그대로 해석한다`() { + val ui = dailyQuestion(createdAt = "2026.07.29 수").toUi() + + assertNotNull(ui) + assertEquals(LocalDate.of(2026, 7, 29), ui!!.date) + } + + @Test + fun `ISO 형식도 해석한다`() { + val ui = dailyQuestion(createdAt = "2026-07-29").toUi() + + assertEquals(LocalDate.of(2026, 7, 29), ui!!.date) + } + + private fun dailyQuestion(createdAt: String) = + DailyQuestionDomain( + dailyQuestionId = 1L, + title = "질문", + content = "답변", + createdAt = createdAt, + isDraft = false, + imageUrl = null, + ) +} From fe998acec7541c384b0696f7b22cc482a88f5b3b Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 16:01:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(mindrecord):=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=EB=AF=B8=ED=99=95=EC=A0=95=20=ED=95=AD=EB=AA=A9=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=EB=A5=BC=20=EC=B6=94=EC=96=B5=20=EA=B3=B5=EA=B0=84?= =?UTF-8?q?=C2=B7=EC=9E=84=EC=8B=9C=EC=A0=80=EC=9E=A5=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop 이 이 브랜치와 별개로 들여온 두 지점이 #751 의 nullable toUi() 와 맞지 않았다. MemorySpaceViewModel 의 데일리질문 카드는 일기 쪽과 같은 방식으로 제외하고, DraftListDeleteTest 는 #769 가 늘린 loader 생성자에 맞춘다. Co-Authored-By: Claude Opus 5 (1M context) --- .../presentation/viewmodel/MemorySpaceViewModel.kt | 7 ++++--- .../presentation/viewmodel/DraftListDeleteTest.kt | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceViewModel.kt index a2745a2550..5f1a2c67d4 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MemorySpaceViewModel.kt @@ -92,7 +92,7 @@ class MemorySpaceViewModel val questions = questionResult.getOrNull().orEmpty() val memories = - (diaries.mapNotNull { it.toDatedMemory() } + questions.map { it.toDatedMemory() }) + (diaries.mapNotNull { it.toDatedMemory() } + questions.mapNotNull { it.toDatedMemory() }) .sortedByDescending { it.date } .take(MEMORY_CARD_LIMIT) .map { it.item } @@ -135,8 +135,9 @@ class MemorySpaceViewModel ) } - private fun DailyQuestion.toDatedMemory(): DatedMemory { - val ui = toUi() + /** 날짜를 못 정한 데일리질문도 같다 — 정렬 키가 없어 카드로 만들지 않는다 (#751). */ + private fun DailyQuestion.toDatedMemory(): DatedMemory? { + val ui = toUi() ?: return null return DatedMemory( date = ui.date, item = diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt index cfcf947974..cb93f3c8c5 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt @@ -100,6 +100,12 @@ class DraftListDeleteTest { private fun viewModel(dailyQuestionRepository: DailyQuestionRepository): DraftListViewModel = DraftListViewModel( + // #769 가 목록 조회를 loader 로 뽑아낸 뒤 생성자가 3개로 늘었다. + loader = + MindRecordDraftLoader( + diaryRepository = EmptyDiaryDraftRepository, + dailyQuestionRepository = dailyQuestionRepository, + ), diaryRepository = EmptyDiaryDraftRepository, dailyQuestionRepository = dailyQuestionRepository, )