diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index f981562dd3..508ed9756c 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { // 컴파일된 리소스로 문구를 검증한다 — aapt2 의 앞뒤 공백 제거는 소스 XML 만 봐서는 잡히지 않는다 (#732). // 열린 주차 메뉴의 스크롤·최하단 선택도 JVM 에서 그대로 재현한다 (#729). + // 매퍼가 android.util.Log 를 타 JVM 단위 테스트로는 돌지 않는다 (#751). testImplementation(libs.robolectric) testImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.test.manifest) 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 276607d739..8d77a3812c 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 2b20b8a804..76e7f28171 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 @@ -138,7 +138,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 927a98ef2f..1bde87a91d 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 @@ -134,8 +134,9 @@ class DraftListViewModel ) } val dailyQuestionItems = - drafts.dailyQuestions.map { question -> - val ui = question.toUi() + // 날짜를 못 정한 항목은 toUi() 가 null 을 돌린다 — 정렬 키가 없어 뺀다 (#751). + drafts.dailyQuestions.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/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/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 d1b19e4e70..3de7bc5a33 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.common.result.runCatchingCancellable @@ -218,7 +219,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() }, ) } @@ -235,6 +236,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 = @@ -243,12 +246,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 = @@ -257,8 +272,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, + ) +}