Skip to content
1 change: 1 addition & 0 deletions feature/mindrecord/presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,14 +20,27 @@ private val DateFormatters: List<DateTimeFormatter> =
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` 을 돌린다 — 호출부가 그 항목을 목록에서 뺀다.
Expand Down Expand Up @@ -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()
Expand All @@ -68,3 +80,5 @@ private fun parseLocalDateOrNull(raw: String?): LocalDate? {
}
return null
}

private const val TAG = "MindRecordUiMapper"
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ class DailyQuestionListViewModel
isAnswered = it.isAnswered,
)
},
answers = phase.answers.map { it.toUi() },
// 날짜를 못 정한 항목은 toUi() 가 null 을 돌린다 — 정렬 키가 없어 카드로 만들지 않는다 (#751).
answers = phase.answers.mapNotNull { it.toUi() },
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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() },
)
}

Expand All @@ -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<WeeklyReportEmotion>): List<EmotionKeyword> =
Expand All @@ -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<DateTimeFormatter> =
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
Loading