From 7d5b6bc8595057267c07657d17ca961de5d370d6 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sun, 23 Aug 2026 21:11:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(mindrecord):=20=EA=B0=90=EC=A0=95=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=ED=95=98=EC=9C=84=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A5=BC=20=ED=99=94=EB=A9=B4=EA=B9=8C=EC=A7=80=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주간리포트 요청 자체는 HTTP 200 이라, AI 감정 분석이 대기 중이거나 실패해도 앱은 `emotions=[]` 만 보고 "키워드 0건" 으로 확정했다. `summaryText` 의 일반 격려 문구도 개인화된 요약이 완료된 것처럼 같은 자리에 표시됐다. 서버가 `emotionAnalysis`(total/succeeded/pending/failed)를 이미 내려주고 있어 (OpenAPI `EmotionAnalysisSummary`, Afternote-BE#117), 이 값을 DTO·domain·UI 상태까지 손실 없이 전달한다. 네 상태를 서로 다른 화면으로 가른다. - 분석 대기: "0" 을 찍지 않고 분석 중 안내를 보여준다 - 분석 실패: 정상 빈 상태로 덮지 않고 재시도 버튼을 둔다 - 분석 완료: 종전대로 키워드와 요약을 보여준다 - 분석 대상 없음: 이때의 0건만 실제 0건이다 탭에 머무르는 동안 분석이 끝나면 반영되도록 제한된 재조회(8초 × 8회)를 둔다. 완료 신호 채널이 없어 폴링으로 기다리며, 그 뒤에는 화면 이탈·복귀와 재시도에 맡긴다. Closes #725 --- .../mindrecord/data/dto/WeeklyReportDto.kt | 22 ++ .../data/mapper/WeeklyReportMapper.kt | 11 + .../data/dto/EmotionAnalysisContractTest.kt | 102 +++++++++ .../data/dto/WeeklyReportContractTest.kt | 10 +- .../domain/model/WeeklyReportModels.kt | 42 ++++ .../component/EmotionKeywordCard.kt | 68 +++++- .../screen/sender/WeeklyReportScreen.kt | 42 +++- .../viewmodel/WeeklyReportUiState.kt | 11 + .../viewmodel/WeeklyReportViewModel.kt | 58 +++++ .../src/main/res/values/strings.xml | 7 + .../WeeklyReportEmotionAnalysisTest.kt | 210 ++++++++++++++++++ 11 files changed, 574 insertions(+), 9 deletions(-) create mode 100644 feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/EmotionAnalysisContractTest.kt create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt index 589cc4b443..5b31f3f330 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt @@ -12,6 +12,28 @@ data class WeeklyReportDto( // 서버 JSON 키가 kebab-case (`daily-question`). @SerialName("daily-question") val dailyQuestions: List = emptyList(), @SerialName("emotions") val emotions: List = emptyList(), + // 기본값을 두지 않는다 — 이 필드가 없으면 "분석 대기" 와 "분석할 것이 없음" 이 다시 + // 구분 불가능해져 이 이슈가 고치려는 상태 소실이 그대로 재현된다 (#725). + @SerialName("emotionAnalysis") val emotionAnalysis: EmotionAnalysisSummaryDto, +) + +/** + * 그 주 감정 분석 진행 상태 (OpenAPI `EmotionAnalysisSummary`). + * + * `emotions` 는 **분석 성공분만** 담기므로, 빈 배열 하나로는 "분석이 끝났는데 키워드가 + * 없음" 과 "아직 분석 중" 과 "분석이 실패함" 이 구분되지 않는다. 그 셋을 가르는 값이 + * 여기 있다 (#725). + */ +@Serializable +data class EmotionAnalysisSummaryDto( + // 분석 대상 기록 수. + @SerialName("total") val total: Int, + // 분석 성공 수. + @SerialName("succeeded") val succeeded: Int, + // 분석 대기/재시도 중 수. + @SerialName("pending") val pending: Int, + // 분석 실패(재시도 소진) 수. + @SerialName("failed") val failed: Int, ) /** diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt index 3174f02b22..a7a4e6b8ec 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt @@ -1,9 +1,11 @@ package com.afternote.feature.mindrecord.data.mapper +import com.afternote.feature.mindrecord.data.dto.EmotionAnalysisSummaryDto import com.afternote.feature.mindrecord.data.dto.WeeklyReportDailyQuestionDto import com.afternote.feature.mindrecord.data.dto.WeeklyReportDayDto import com.afternote.feature.mindrecord.data.dto.WeeklyReportDto import com.afternote.feature.mindrecord.data.dto.WeeklyReportEmotionDto +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion import com.afternote.feature.mindrecord.domain.model.WeeklyReportDay @@ -17,6 +19,7 @@ fun WeeklyReportDto.toDomain(): WeeklyReport = week = week.map { it.toDomain() }, dailyQuestions = dailyQuestions.map { it.toDomain() }, emotions = emotions.map { it.toDomain() }, + emotionAnalysis = emotionAnalysis.toDomain(), ) /** `week[].type` 중 일기를 뜻하는 값. 나머지(`DAILY_QUESTION`·`DEEP_THOUGHT`·미래 종류)는 일기가 아니다. */ @@ -43,6 +46,14 @@ fun WeeklyReportDailyQuestionDto.toDomain(): WeeklyReportDailyQuestion = date = date, ) +fun EmotionAnalysisSummaryDto.toDomain(): EmotionAnalysis = + EmotionAnalysis( + total = total, + succeeded = succeeded, + pending = pending, + failed = failed, + ) + fun WeeklyReportEmotionDto.toDomain(): WeeklyReportEmotion = WeeklyReportEmotion( keyword = keyword, diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/EmotionAnalysisContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/EmotionAnalysisContractTest.kt new file mode 100644 index 0000000000..a5b32a8293 --- /dev/null +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/EmotionAnalysisContractTest.kt @@ -0,0 +1,102 @@ +package com.afternote.feature.mindrecord.data.dto + +import com.afternote.core.network.model.BaseResponse +import com.afternote.feature.mindrecord.data.mapper.toDomain +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus +import kotlinx.serialization.MissingFieldException +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * 감정 분석 상태 계약 가드 (#725, OpenAPI `EmotionAnalysisSummary`). + * + * `emotions` 는 **분석 성공분만** 담기므로, 빈 배열 하나로는 "분석이 끝났는데 키워드가 + * 없음" 과 "아직 분석 중" 과 "분석 실패" 가 구분되지 않는다. 그 셋을 가르는 값이 + * `emotionAnalysis` 라, 이 필드가 소실되면 이슈가 고치려는 오표시가 그대로 재현된다. + * + * Json 설정은 `NetworkModule.provideJson` 과 동일 (ignoreUnknownKeys + coerceInputValues). + */ +class EmotionAnalysisContractTest { + private val json = + Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + private fun decodeStatus(analysisJson: String): EmotionAnalysisStatus { + val body = + """ + { "status": 200, "code": 200, "data": { + "week": [], "emotions": [], "summaryText": "", + "emotionAnalysis": $analysisJson } } + """.trimIndent() + return json + .decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) + .data!! + .toDomain() + .emotionAnalysis + .status + } + + @Test + fun `분석 대상이 없으면 키워드 0건이 정상이다`() { + val status = decodeStatus("""{ "total": 0, "succeeded": 0, "pending": 0, "failed": 0 }""") + + assertEquals(EmotionAnalysisStatus.NOTHING_TO_ANALYZE, status) + } + + @Test + fun `대기 중인 기록이 하나라도 있으면 대기다`() { + // 실서버 실측(2026-08-23) — 일기 저장 직후 1분 넘게 이 상태가 유지됐다. + // 이때 emotions 는 [] 라, 이 값이 없으면 앱이 "키워드 0건" 으로 확정해 버린다. + val status = decodeStatus("""{ "total": 1, "succeeded": 0, "pending": 1, "failed": 0 }""") + + assertEquals(EmotionAnalysisStatus.PENDING, status) + } + + @Test + fun `성공분이 있어도 대기가 남아 있으면 대기다`() { + // 부분 성공 중간 상태 — 아직 키워드가 더 붙을 수 있으므로 확정하지 않는다. + val status = decodeStatus("""{ "total": 2, "succeeded": 1, "pending": 1, "failed": 0 }""") + + assertEquals(EmotionAnalysisStatus.PENDING, status) + } + + @Test + fun `재시도까지 소진해 아무것도 못 건지면 실패다`() { + val status = decodeStatus("""{ "total": 2, "succeeded": 0, "pending": 0, "failed": 2 }""") + + assertEquals(EmotionAnalysisStatus.FAILED, status) + } + + @Test + fun `일부만 실패했고 성공분이 있으면 완료로 본다`() { + // 건진 키워드를 보여주는 편이 낫다 — 실패 화면으로 덮으면 있는 결과까지 가린다. + val status = decodeStatus("""{ "total": 3, "succeeded": 2, "pending": 0, "failed": 1 }""") + + assertEquals(EmotionAnalysisStatus.COMPLETED, status) + } + + @Test + fun `전부 성공하면 완료다`() { + val status = decodeStatus("""{ "total": 3, "succeeded": 3, "pending": 0, "failed": 0 }""") + + assertEquals(EmotionAnalysisStatus.COMPLETED, status) + } + + @Test + fun `emotionAnalysis 키가 빠지면 기본값으로 성공하지 않고 실패한다`() { + // 기본값을 두면 "분석 대기" 가 다시 "분석할 것 없음" 으로 접혀 이 이슈가 재현된다. + val body = + """ + { "status": 200, "code": 200, + "data": { "week": [], "emotions": [], "summaryText": "" } } + """.trimIndent() + + assertThrows(MissingFieldException::class.java) { + json.decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) + } + } +} diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt index 5f29bcc149..68d62948c8 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/WeeklyReportContractTest.kt @@ -36,7 +36,9 @@ class WeeklyReportContractTest { private fun decodeWeek(weekJson: String): List { val body = """ - { "status": 200, "code": 200, "data": { "week": $weekJson } } + { "status": 200, "code": 200, "data": { + "week": $weekJson, + "emotionAnalysis": { "total": 1, "succeeded": 1, "pending": 0, "failed": 0 } } } """.trimIndent() return json .decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) @@ -103,7 +105,11 @@ class WeeklyReportContractTest { @Test fun `week 키가 없으면 빈 목록으로 접힌다`() { - val body = """{ "status": 200, "code": 200, "data": { "summaryText": "" } }""" + val body = + """ + { "status": 200, "code": 200, "data": { "summaryText": "", + "emotionAnalysis": { "total": 0, "succeeded": 0, "pending": 0, "failed": 0 } } } + """.trimIndent() val decoded = json.decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt index 25eee759ef..a9f826f5a7 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt @@ -7,8 +7,50 @@ data class WeeklyReport( val week: List, val dailyQuestions: List, val emotions: List, + val emotionAnalysis: EmotionAnalysis, ) +/** + * 그 주 감정 분석 진행 상태. + * + * [WeeklyReport.emotions] 는 **분석 성공분만** 담기므로 빈 목록 하나로는 아래 셋을 + * 구분할 수 없다. 그 판단을 [status] 한 곳에 모아 둔다 (#725). + */ +data class EmotionAnalysis( + val total: Int, + val succeeded: Int, + val pending: Int, + val failed: Int, +) { + val status: EmotionAnalysisStatus + get() = + when { + total == 0 -> EmotionAnalysisStatus.NOTHING_TO_ANALYZE + + pending > 0 -> EmotionAnalysisStatus.PENDING + + // 일부만 실패했어도 성공분이 있으면 그 키워드를 보여주는 편이 낫다. + // 아무것도 건지지 못한 경우만 실패로 본다. + succeeded == 0 && failed > 0 -> EmotionAnalysisStatus.FAILED + + else -> EmotionAnalysisStatus.COMPLETED + } +} + +enum class EmotionAnalysisStatus { + /** 그 주에 분석할 기록 자체가 없다 — 키워드 0건이 정상이다. */ + NOTHING_TO_ANALYZE, + + /** 아직 분석 중이다 — 키워드 0건을 확정하면 안 된다. */ + PENDING, + + /** 재시도까지 소진해 아무것도 분석하지 못했다 — 재조회 경로가 필요하다. */ + FAILED, + + /** 분석이 끝났다. 키워드가 0건이면 실제로 0건인 것이다. */ + COMPLETED, +} + data class WeeklyReportDay( val diaryId: Long, val day: Int, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/EmotionKeywordCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/EmotionKeywordCard.kt index 8304d781ac..39929f0527 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/EmotionKeywordCard.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/EmotionKeywordCard.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CardDefaults import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -26,6 +27,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.model.EmotionKeyword @@ -40,15 +42,24 @@ import com.afternote.feature.mindrecord.presentation.model.EmotionKeyword * - 2건: 가족(96) / 감사(72) * - 1건: 가족(96) * - 0건: 빈 96dp 검은 원에 "0" 만 표시 + 별도 안내 메시지(호출부 책임) + * + * **"0" 은 분석이 정상 종료됐을 때만 찍는다.** `emotions` 는 분석 성공분만 담기므로 빈 + * 목록 하나로는 "실제로 0건" 과 "아직 분석 중" 과 "분석 실패" 가 구분되지 않는다. + * 대기·실패 상태에서 0 을 찍으면 키워드가 없다고 확정해 버린다 (#725). */ @Composable fun EmotionKeywordCard( keywords: List, descriptionText: String, + analysisStatus: EmotionAnalysisStatus, modifier: Modifier = Modifier, title: String = stringResource(R.string.mindrecord_emotion_card_title), + onRetry: (() -> Unit)? = null, ) { val capped = keywords.take(MAX_KEYWORDS) + val confirmsEmptyCount = + analysisStatus == EmotionAnalysisStatus.COMPLETED || + analysisStatus == EmotionAnalysisStatus.NOTHING_TO_ANALYZE OutlinedCard( colors = CardDefaults.cardColors(containerColor = Color.White), @@ -74,7 +85,7 @@ fun EmotionKeywordCard( .height(BUBBLE_AREA_HEIGHT), ) { if (capped.isEmpty()) { - EmptyBubble() + if (confirmsEmptyCount) EmptyBubble() else PendingBubble() } else { val slots = slotsFor(capped.size) capped.forEachIndexed { index, keyword -> @@ -88,6 +99,16 @@ fun EmotionKeywordCard( style = AfternoteDesign.typography.bodySmallB, color = AfternoteDesign.colors.gray9, ) + + if (analysisStatus == EmotionAnalysisStatus.FAILED && onRetry != null) { + TextButton(onClick = onRetry) { + Text( + text = stringResource(R.string.mindrecord_emotion_card_retry), + style = AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + } + } } } } @@ -126,6 +147,23 @@ private fun Bubble( } } +/** + * 분석이 끝나지 않았을 때의 자리 표시. [EmptyBubble] 과 크기·위치는 같지만 **"0" 을 찍지 + * 않는다** — 키워드가 없다고 확정하는 표시이기 때문이다 (#725). + */ +@Composable +private fun PendingBubble() { + val slot = EMPTY_SLOT + Box( + modifier = + Modifier + .offset(x = slot.offsetX, y = slot.offsetY) + .size(slot.size) + .clip(CircleShape) + .background(slot.color.copy(alpha = 0.3f)), + ) +} + @Composable private fun EmptyBubble() { val slot = EMPTY_SLOT @@ -218,6 +256,7 @@ private fun EmotionKeywordCardPreview4() { EmotionKeyword("그리움", 8), ), descriptionText = "이번 주 박서연 님의 기록에서는 '가족'을 위한 '감사'의 마음이 엿보입니다.", + analysisStatus = EmotionAnalysisStatus.COMPLETED, ) } } @@ -229,6 +268,7 @@ private fun EmotionKeywordCardPreview1() { EmotionKeywordCard( keywords = listOf(EmotionKeyword("가족", 8)), descriptionText = "이번 주 박서연 님의 기록에서는 '가족'의 마음이 엿보입니다.", + analysisStatus = EmotionAnalysisStatus.COMPLETED, ) } } @@ -240,6 +280,32 @@ private fun EmotionKeywordCardPreview0() { EmotionKeywordCard( keywords = emptyList(), descriptionText = "이번 주 박서연 님의 기록에서는 키워드가 나오지 않았어요.", + analysisStatus = EmotionAnalysisStatus.COMPLETED, + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5, widthDp = 360, name = "분석 대기") +@Composable +private fun EmotionKeywordCardPreviewPending() { + AfternoteTheme { + EmotionKeywordCard( + keywords = emptyList(), + descriptionText = "기록을 분석하고 있어요. 잠시 뒤 이 자리에 키워드가 채워집니다.", + analysisStatus = EmotionAnalysisStatus.PENDING, + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFF5F5F5, widthDp = 360, name = "분석 실패") +@Composable +private fun EmotionKeywordCardPreviewFailed() { + AfternoteTheme { + EmotionKeywordCard( + keywords = emptyList(), + descriptionText = "감정 분석에 실패했어요. 다시 시도해 주세요.", + analysisStatus = EmotionAnalysisStatus.FAILED, + onRetry = {}, ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt index 3f2a4202d2..25ae86fa73 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt @@ -26,6 +26,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.asString import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.component.DailyQuestionListCard import com.afternote.feature.mindrecord.presentation.component.EmotionKeywordCard @@ -54,6 +55,7 @@ fun WeeklyReportScreen( WeeklyReportContent( state = state, onWeekSelect = viewModel::selectWeek, + onEmotionAnalysisRetry = viewModel::retryEmotionAnalysis, modifier = modifier, ) } @@ -64,6 +66,7 @@ fun WeeklyReportScreen( private fun WeeklyReportContent( state: WeeklyReportUiState.Success, onWeekSelect: (java.time.LocalDate) -> Unit, + onEmotionAnalysisRetry: () -> Unit, modifier: Modifier = Modifier, ) { // Figma 노드 852:11543 — main 컨테이너의 섹션 간 gap=32, 시작 pt=8, 끝 pb=200. @@ -103,12 +106,9 @@ private fun WeeklyReportContent( SectionHeader(title = "TOP KEYWORDS") EmotionKeywordCard( keywords = state.emotionKeywords, - descriptionText = - if (state.emotionKeywords.isEmpty()) { - stringResource(R.string.mindrecord_emotion_card_empty_description, state.userName) - } else { - state.summaryText - }, + descriptionText = emotionCardDescription(state), + analysisStatus = state.emotionAnalysisStatus, + onRetry = onEmotionAnalysisRetry, ) } } @@ -191,6 +191,34 @@ private fun ErrorBox( } } +/** + * 감정 카드 본문 문구. + * + * 키워드가 없을 때 무엇을 적을지는 **분석 상태**가 정한다. 종전에는 빈 목록이면 무조건 + * "키워드가 나오지 않았어요" 를 적어, 분석 대기·실패까지 정상 빈 상태로 확정했다 (#725). + * + * 키워드가 있으면 서버 요약을 그대로 쓴다 — 분석이 끝난 뒤에만 도달하는 자리다. + */ +@Composable +private fun emotionCardDescription(state: WeeklyReportUiState.Success): String = + when { + state.emotionKeywords.isNotEmpty() -> { + state.summaryText + } + + state.emotionAnalysisStatus == EmotionAnalysisStatus.PENDING -> { + stringResource(R.string.mindrecord_emotion_card_pending_description) + } + + state.emotionAnalysisStatus == EmotionAnalysisStatus.FAILED -> { + stringResource(R.string.mindrecord_emotion_card_failed_description) + } + + else -> { + stringResource(R.string.mindrecord_emotion_card_empty_description, state.userName) + } + } + @Preview(showBackground = true) @Composable private fun WeeklyReportScreenPreview() { @@ -206,10 +234,12 @@ private fun WeeklyReportScreenPreview() { counts = emptyList(), weekDays = emptyList(), emotionKeywords = emptyList(), + emotionAnalysisStatus = EmotionAnalysisStatus.COMPLETED, summaryText = "이번 주는 차분히 마음을 정리한 한 주였어요.", dailyQuestions = emptyList(), ), onWeekSelect = {}, + onEmotionAnalysisRetry = {}, ) } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportUiState.kt index 5bc859f615..0bebd964e6 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportUiState.kt @@ -1,6 +1,7 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus import com.afternote.feature.mindrecord.presentation.model.DailyQuestion import com.afternote.feature.mindrecord.presentation.model.DayItem import com.afternote.feature.mindrecord.presentation.model.EmotionKeyword @@ -26,6 +27,16 @@ sealed interface WeeklyReportUiState { val counts: List>, val weekDays: List, val emotionKeywords: List, + /** + * 감정 분석 진행 상태. [emotionKeywords] 가 비었을 때 그것이 "실제로 0건" 인지 + * "아직 분석 중" 인지 "분석 실패" 인지를 이 값만 가르므로, 빈 목록만 보고 0건을 + * 확정하면 안 된다 (#725). + */ + val emotionAnalysisStatus: EmotionAnalysisStatus, + /** + * 주간 요약. 분석이 끝나기 전에는 서버가 일반 격려 문구를 내려주므로, + * [emotionAnalysisStatus] 가 완료가 아닐 때 개인화된 요약처럼 보여주면 안 된다. + */ val summaryText: String, val dailyQuestions: List, ) : WeeklyReportUiState 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..959e09a4af 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 @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.domain.repository.UserRepository import com.afternote.core.ui.UiText +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion @@ -21,6 +22,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -62,6 +65,17 @@ class WeeklyReportViewModel fun selectWeek(monday: LocalDate) = load(monday) + /** + * 감정 분석 실패 상태에서 사용자가 누르는 재시도. + * + * FE 가 할 수 있는 것은 **재조회**뿐이다 — 서버 측 재분석 트리거는 Afternote-BE#117 + * 소관이다. 보고 있던 주를 그대로 다시 불러 상태를 갱신한다 (#725). + */ + fun retryEmotionAnalysis() { + val current = (internalState.value.loadPhase as? LoadPhase.Loaded)?.monday ?: return + load(current, showsLoading = false, keepsStateOnFailure = true) + } + /** * 탭 전환·작성 화면 복귀 등 사용자가 요청하지 않은 자동 갱신. * @@ -109,6 +123,7 @@ class WeeklyReportViewModel internalState.update { it.copy(loadPhase = LoadPhase.Loaded(monday, report, profile.name)) } + awaitEmotionAnalysis(monday, report.emotionAnalysis.status) }.onFailure { e -> internalState.update { current -> if (keepsStateOnFailure && current.loadPhase is LoadPhase.Loaded) { @@ -129,6 +144,43 @@ class WeeklyReportViewModel } } + /** + * 감정 분석이 끝나기를 화면에 머무른 채 기다린다. + * + * 분석은 저장 직후 비동기로 돌고 완료 신호를 주는 채널이 없어서, 탭에 머무르는 동안 + * 결과가 반영되려면 제한된 재조회가 필요하다 (#725). 무한 폴링은 하지 않는다 — + * [EMOTION_ANALYSIS_POLL_ATTEMPTS] 회만 시도하고, 그 뒤에는 화면 이탈·복귀 + * ([refreshOnReturn])나 사용자의 재시도에 맡긴다. + * + * 이 로직은 [load] 의 코루틴 안에서 돈다. 사용자가 다른 주를 고르면 그 `loadJob` + * 취소가 이 대기까지 함께 끊는다. + */ + private suspend fun awaitEmotionAnalysis( + monday: LocalDate, + initialStatus: EmotionAnalysisStatus, + ) { + var status = initialStatus + repeat(EMOTION_ANALYSIS_POLL_ATTEMPTS) { + if (status != EmotionAnalysisStatus.PENDING) return + delay(EMOTION_ANALYSIS_POLL_INTERVAL_MILLIS) + val report = + repository + .getWeeklyReport(date = monday.format(API_DATE_FORMATTER)) + .getOrNull() ?: return + currentCoroutineContext().ensureActive() + status = report.emotionAnalysis.status + internalState.update { current -> + val phase = current.loadPhase + // 그 사이 다른 주로 옮겨갔으면 덮어쓰지 않는다. + if (phase is LoadPhase.Loaded && phase.monday == monday) { + current.copy(loadPhase = phase.copy(report = report)) + } else { + current + } + } + } + } + private fun buildWeekOptions( today: LocalDate, count: Int, @@ -220,6 +272,7 @@ class WeeklyReportViewModel report.dailyQuestionAmount to MindRecordCategoryUi.DailyQuestion, report.diaryAmount to MindRecordCategoryUi.Diary, ), + emotionAnalysisStatus = report.emotionAnalysis.status, weekDays = mapWeekDays(monday, report.week), emotionKeywords = mapEmotionKeywords(report.emotions), summaryText = report.summaryText, @@ -230,6 +283,11 @@ class WeeklyReportViewModel companion object { private const val WEEK_OPTION_COUNT = 5 + // 분석 완료 신호를 주는 채널이 없어 재조회로 기다린다. 8초 × 8회 ≈ 1분 — + // 그 뒤에는 화면 이탈·복귀나 사용자의 재시도에 맡긴다 (#725). + private const val EMOTION_ANALYSIS_POLL_ATTEMPTS = 8 + private const val EMOTION_ANALYSIS_POLL_INTERVAL_MILLIS = 8_000L + private val API_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") private val RANGE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd.") diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index 66dce6e8af..2d0e1c4154 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -95,6 +95,13 @@ 나의 감정 키워드 이번 주 박서연 님의 기록에서는\n\'가족\'을 위한 \'감사\'의 마음이 엿보입니다. 이번 주 %1$s 님의 기록에서는 키워드가 나오지 않았어요. + + 기록을 분석하고 있어요. 잠시 뒤 이 자리에 키워드가 채워집니다. + + 감정 분석에 실패했어요. 다시 시도해 주세요. + 다시 시도 + + 분석 중 임시 안내예요. 분석이 끝나면 이번 주 요약으로 바뀝니다. 아직 등록된 답변이 없어요. diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt new file mode 100644 index 0000000000..561a7a99ae --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt @@ -0,0 +1,210 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.model.user.User +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus +import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.mindrecord.domain.model.WeeklyReportEmotion +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +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.assertTrue +import org.junit.Before +import org.junit.Test +import java.lang.reflect.Proxy + +/** + * 감정 분석 하위 상태의 화면 전이 가드 (#725). + * + * 주간리포트 요청 자체는 HTTP 200 이라, 분석 대기·실패가 `emotions=[]` 로만 도착한다. + * 종전에는 그 빈 목록을 "키워드 0건" 으로 확정해 정상 리포트처럼 보여줬다. + * + * 여기서 고정하는 전이: **대기→완료, 대기→실패, 실패→재시도, 실제 빈 결과**. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WeeklyReportEmotionAnalysisTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `분석 대기는 키워드 0건으로 확정되지 않는다`() = + runTest(dispatcher) { + // 실서버 실측(2026-08-23) 형태 — 일기 1건 저장 직후 pending 이 유지되고 emotions 는 []. + val viewModel = viewModel(listOf(report(total = 1, pending = 1))) + collectUiState(viewModel) + advanceUntilIdle() + + val state = viewModel.uiState.value as WeeklyReportUiState.Success + assertTrue(state.emotionKeywords.isEmpty()) + assertEquals(EmotionAnalysisStatus.PENDING, state.emotionAnalysisStatus) + } + + @Test + fun `대기에서 완료로 바뀌면 머무른 화면에 결과가 반영된다`() = + runTest(dispatcher) { + // 분석 완료를 알려주는 채널이 없어 제한된 재조회로 기다린다 — 그 사이 화면을 떠나지 않는다. + val viewModel = + viewModel( + listOf( + report(total = 1, pending = 1), + report(total = 1, succeeded = 1, emotions = listOf(WeeklyReportEmotion("평온", 100))), + ), + ) + collectUiState(viewModel) + advanceUntilIdle() + + val state = viewModel.uiState.value as WeeklyReportUiState.Success + assertEquals(EmotionAnalysisStatus.COMPLETED, state.emotionAnalysisStatus) + assertEquals(listOf("평온"), state.emotionKeywords.map { it.keyword }) + } + + @Test + fun `대기에서 실패로 바뀌면 정상 빈 상태로 덮지 않는다`() = + runTest(dispatcher) { + val viewModel = + viewModel( + listOf( + report(total = 2, pending = 2), + report(total = 2, failed = 2), + ), + ) + collectUiState(viewModel) + advanceUntilIdle() + + val state = viewModel.uiState.value as WeeklyReportUiState.Success + assertEquals(EmotionAnalysisStatus.FAILED, state.emotionAnalysisStatus) + } + + @Test + fun `실패 상태에서 재시도하면 다시 조회해 완료로 바뀐다`() = + runTest(dispatcher) { + val viewModel = + viewModel( + listOf( + report(total = 1, failed = 1), + report(total = 1, succeeded = 1, emotions = listOf(WeeklyReportEmotion("감사", 100))), + ), + ) + collectUiState(viewModel) + advanceUntilIdle() + assertEquals( + EmotionAnalysisStatus.FAILED, + (viewModel.uiState.value as WeeklyReportUiState.Success).emotionAnalysisStatus, + ) + + viewModel.retryEmotionAnalysis() + advanceUntilIdle() + + val state = viewModel.uiState.value as WeeklyReportUiState.Success + assertEquals(EmotionAnalysisStatus.COMPLETED, state.emotionAnalysisStatus) + assertEquals(listOf("감사"), state.emotionKeywords.map { it.keyword }) + } + + @Test + fun `분석 대상이 없으면 그 자리에서 완료된 빈 결과다`() = + runTest(dispatcher) { + // 기록이 하나도 없는 주 — 이때의 0건만 "실제로 0건" 이다. + val viewModel = viewModel(listOf(report(total = 0))) + collectUiState(viewModel) + advanceUntilIdle() + + val state = viewModel.uiState.value as WeeklyReportUiState.Success + assertEquals(EmotionAnalysisStatus.NOTHING_TO_ANALYZE, state.emotionAnalysisStatus) + } + + @Test + fun `대기가 끝나지 않아도 무한히 재조회하지 않는다`() = + runTest(dispatcher) { + // 실측처럼 계속 pending 인 경우 — 폴링이 멈추고 화면은 대기 상태로 남는다. + var calls = 0 + val viewModel = + viewModel(generateSequence { report(total = 1, pending = 1) }.take(50).toList()) { calls++ } + collectUiState(viewModel) + advanceUntilIdle() + val callsAfterPolling = calls + + advanceTimeBy(10 * 60 * 1000L) + advanceUntilIdle() + + assertEquals("폴링이 끝난 뒤에는 더 조회하지 않는다", callsAfterPolling, calls) + assertTrue("최초 조회 + 제한된 재조회만 있어야 한다", calls in 2..12) + } + + // ── 테스트 도구 ─────────────────────────────────────────────────────────── + + /** + * `uiState` 는 `WhileSubscribed` 라 구독자가 없으면 `Loading` 에 머문다. + * 화면이 떠 있는 상황을 재현하려면 수집을 켜 둬야 한다. + */ + private fun TestScope.collectUiState(viewModel: WeeklyReportViewModel) { + backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } + } + + private fun report( + total: Int, + succeeded: Int = 0, + pending: Int = 0, + failed: Int = 0, + emotions: List = emptyList(), + ) = WeeklyReport( + dailyQuestionAmount = 0, + diaryAmount = if (total > 0) 1 else 0, + summaryText = "이번 주 기록을 바탕으로 인사이트를 준비 중이에요.", + week = emptyList(), + dailyQuestions = emptyList(), + emotions = emotions, + emotionAnalysis = + EmotionAnalysis(total = total, succeeded = succeeded, pending = pending, failed = failed), + ) + + /** [responses] 를 순서대로 돌려주고, 다 쓰면 마지막 응답을 계속 준다. */ + private fun viewModel( + responses: List, + onCall: () -> Unit = {}, + ): WeeklyReportViewModel { + var index = 0 + val repository = + object : WeeklyReportRepository { + override suspend fun getWeeklyReport(date: String): Result { + onCall() + val report = responses[minOf(index, responses.lastIndex)] + index++ + return Result.success(report) + } + } + return WeeklyReportViewModel(repository, userRepository()) + } + + private fun userRepository(): UserRepository = + Proxy.newProxyInstance( + UserRepository::class.java.classLoader, + arrayOf(UserRepository::class.java), + ) { _, method, _ -> + when (method.name) { + "getReceiverListFlow" -> flowOf(emptyList()) + "getMyProfile" -> User(name = "adamtia", email = "a@b.c", phone = null, profileImageUrl = null) + else -> error("Unexpected user repository call: ${method.name}") + } + } as UserRepository +} From 30ddbdd899e1dabe3c9fb9bdafb570c5dd3c9b8e Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 17:05:39 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(mindrecord):=20=EA=B0=90=EC=A0=95=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EA=B0=80=20=EC=83=81=ED=83=9C=EB=A5=BC=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=EB=B3=B4=EB=8B=A4=20=EB=A8=BC?= =?UTF-8?q?=EC=A0=80=20=EB=B3=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 부분 성공(일부 완료 + 일부 대기)에서는 완료분 키워드가 emotions 에 실려 내려온다 — BE buildTopEmotions 에 완료 게이트가 없다. 키워드 유무를 먼저 보면 아직 분석 중인데도 폴백 요약이 최종 요약처럼 확정되고 대기 분기는 도달 불가가 됐다. 함께 정리한 것: - emotionAnalysis 를 필수에서 nullable 로 낮추고 null 을 UNKNOWN 으로 옮긴다. 필수면 필드 하나가 빠졌을 때 탭 전체가 오류 화면이 되고 예외 원문까지 노출된다. 0 으로 접지 않는다는 목적은 UNKNOWN 이 그대로 지킨다 - FAILED 가 PendingBubble 로 떨어져 대기와 시각적으로 같던 것을 가르고, 빈 Box 라 TalkBack 이 아무것도 읽지 않던 자리에 설명을 붙인다. 대기 버블은 shimmer 로 로딩임을 알린다 - 폴링 한 번 실패가 남은 시도를 전부 버리던 것을 시도 소모로 바꾼다. 지난 주를 보는 동안에는 폴링하지 않는다 — 근거가 «저장 직후 비동기 분석» 이라 이번 주에만 성립한다 - 재시도를 refreshOnReturn 에 위임해 재조회 정책을 한 곳에 두고, 사용자가 누른 갱신은 진행 표시를 낸다. 라벨도 «다시 시도» → «다시 확인» 으로 — FE 가 할 수 있는 것은 재조회뿐이다 - 계약 테스트가 프로덕션 Json 설정을 복제하지 않고 NetworkModule.provideJson() 을 쓴다 - 폴링 횟수 단언을 범위에서 결정값(9)으로 조인다 - androidTest 3곳의 WeeklyReport 생성자 갱신 리뷰 지적. Co-Authored-By: Claude Opus 5 (1M context) --- .../MindRecordLifecycleAndroidTest.kt | 3 + ...meLetterMindRecordCompletionAndroidTest.kt | 5 ++ .../mindrecord/data/dto/WeeklyReportDto.kt | 9 +- .../data/mapper/WeeklyReportMapper.kt | 2 +- .../data/dto/EmotionAnalysisContractTest.kt | 56 ++++++++---- .../domain/model/WeeklyReportModels.kt | 11 ++- .../mindrecord/presentation/build.gradle.kts | 7 ++ .../component/EmotionKeywordCard.kt | 72 ++++++++++++--- .../screen/sender/WeeklyReportScreen.kt | 39 +++++--- .../viewmodel/WeeklyReportViewModel.kt | 31 +++++-- .../src/main/res/values/strings.xml | 10 ++- .../sender/EmotionCardDescriptionTest.kt | 89 +++++++++++++++++++ .../WeeklyReportEmotionAnalysisTest.kt | 33 ++++++- 13 files changed, 314 insertions(+), 53 deletions(-) create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/EmotionCardDescriptionTest.kt diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt index 716a82fbbc..37df6fe8bd 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt @@ -22,6 +22,7 @@ import com.afternote.feature.mindrecord.domain.model.Diary import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryList import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis import com.afternote.feature.mindrecord.domain.model.MindRecordSummary import com.afternote.feature.mindrecord.domain.model.MindRecordType import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords @@ -231,6 +232,8 @@ class MindRecordLifecycleAndroidTest { WeeklyReportDailyQuestion("다른 날 질문", "답변 B", friday.toString()), ), emotions = emptyList(), + // 이 테스트들은 분석 상태를 보지 않는다 — 완료로 고정한다 (#725). + emotionAnalysis = EmotionAnalysis(total = 0, succeeded = 0, pending = 0, failed = 0), ), ), ) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt index d48fda550a..6d0ab3b57e 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -41,6 +41,7 @@ import com.afternote.feature.mindrecord.domain.model.Diary import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryList import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.domain.model.WeeklyReport @@ -918,6 +919,8 @@ private fun emptyWeeklyReport(): WeeklyReport = week = emptyList(), dailyQuestions = emptyList(), emotions = emptyList(), + // 이 테스트들은 분석 상태를 보지 않는다 — 완료로 고정한다 (#725). + emotionAnalysis = EmotionAnalysis(total = 0, succeeded = 0, pending = 0, failed = 0), ) private fun completeWeeklyReport(monday: LocalDate): WeeklyReport = @@ -947,4 +950,6 @@ private fun completeWeeklyReport(monday: LocalDate): WeeklyReport = WeeklyReportEmotion(keyword = "가족", percentage = 25), WeeklyReportEmotion(keyword = "감사", percentage = 75), ), + // 키워드가 나온 완료 상태 — 1건 분석해 1건 성공 (#725). + emotionAnalysis = EmotionAnalysis(total = 1, succeeded = 1, pending = 0, failed = 0), ) diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt index 5b31f3f330..72153c3b92 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/dto/WeeklyReportDto.kt @@ -12,9 +12,14 @@ data class WeeklyReportDto( // 서버 JSON 키가 kebab-case (`daily-question`). @SerialName("daily-question") val dailyQuestions: List = emptyList(), @SerialName("emotions") val emotions: List = emptyList(), - // 기본값을 두지 않는다 — 이 필드가 없으면 "분석 대기" 와 "분석할 것이 없음" 이 다시 + // 보정형 기본값을 두지 않는다 — 0 으로 접으면 "분석 대기" 와 "분석할 것이 없음" 이 다시 // 구분 불가능해져 이 이슈가 고치려는 상태 소실이 그대로 재현된다 (#725). - @SerialName("emotionAnalysis") val emotionAnalysis: EmotionAnalysisSummaryDto, + // + // 다만 **필수로 두지도 않는다.** 필수면 이 필드 하나가 빠졌을 때 MissingFieldException 이 + // 나 주간리포트 탭 전체가 오류 화면이 되고, 예외 원문까지 노출된다. null 은 0 이 아니라 + // "모른다"(UNKNOWN) 로 옮겨지므로 확정하지 않는다는 목적은 그대로 지키면서 실패 반경이 + // 카드 한 장으로 줄어든다. + @SerialName("emotionAnalysis") val emotionAnalysis: EmotionAnalysisSummaryDto? = null, ) /** diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt index a7a4e6b8ec..d83510bd85 100644 --- a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/WeeklyReportMapper.kt @@ -19,7 +19,7 @@ fun WeeklyReportDto.toDomain(): WeeklyReport = week = week.map { it.toDomain() }, dailyQuestions = dailyQuestions.map { it.toDomain() }, emotions = emotions.map { it.toDomain() }, - emotionAnalysis = emotionAnalysis.toDomain(), + emotionAnalysis = emotionAnalysis?.toDomain(), ) /** `week[].type` 중 일기를 뜻하는 값. 나머지(`DAILY_QUESTION`·`DEEP_THOUGHT`·미래 종류)는 일기가 아니다. */ diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/EmotionAnalysisContractTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/EmotionAnalysisContractTest.kt index a5b32a8293..9501ff1661 100644 --- a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/EmotionAnalysisContractTest.kt +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/EmotionAnalysisContractTest.kt @@ -1,12 +1,13 @@ package com.afternote.feature.mindrecord.data.dto +import com.afternote.core.network.di.NetworkModule import com.afternote.core.network.model.BaseResponse import com.afternote.feature.mindrecord.data.mapper.toDomain import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus -import kotlinx.serialization.MissingFieldException +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows +import org.junit.Assert.assertNull import org.junit.Test /** @@ -16,16 +17,14 @@ import org.junit.Test * 없음" 과 "아직 분석 중" 과 "분석 실패" 가 구분되지 않는다. 그 셋을 가르는 값이 * `emotionAnalysis` 라, 이 필드가 소실되면 이슈가 고치려는 오표시가 그대로 재현된다. * - * Json 설정은 `NetworkModule.provideJson` 과 동일 (ignoreUnknownKeys + coerceInputValues). + * Json 은 프로덕션 설정을 **복제하지 않고 그대로 가져다 쓴다** — 복제하면 앱 설정이 + * 바뀔 때 이 테스트만 조용히 어긋난다. */ +@OptIn(ExperimentalSerializationApi::class) class EmotionAnalysisContractTest { - private val json = - Json { - ignoreUnknownKeys = true - coerceInputValues = true - } + private val json = NetworkModule.provideJson() - private fun decodeStatus(analysisJson: String): EmotionAnalysisStatus { + private fun decodeStatus(analysisJson: String): EmotionAnalysisStatus? { val body = """ { "status": 200, "code": 200, "data": { @@ -37,7 +36,7 @@ class EmotionAnalysisContractTest { .data!! .toDomain() .emotionAnalysis - .status + ?.status } @Test @@ -87,16 +86,43 @@ class EmotionAnalysisContractTest { } @Test - fun `emotionAnalysis 키가 빠지면 기본값으로 성공하지 않고 실패한다`() { - // 기본값을 두면 "분석 대기" 가 다시 "분석할 것 없음" 으로 접혀 이 이슈가 재현된다. + fun `emotionAnalysis 키가 빠져도 0 건으로 접히지 않는다`() { + // 보정형 기본값을 두면 "분석 대기" 가 "분석할 것 없음" 으로 접혀 이 이슈가 재현된다. + // 그렇다고 필수로 두면 이 필드 하나에 주간리포트 탭 전체가 오류 화면이 되므로, + // null 로 받아 «모른다» 로 옮긴다 — 확정하지 않는다는 목적은 그대로다. val body = """ { "status": 200, "code": 200, "data": { "week": [], "emotions": [], "summaryText": "" } } """.trimIndent() - assertThrows(MissingFieldException::class.java) { - json.decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) - } + val report = + json + .decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) + .data!! + .toDomain() + + assertNull(report.emotionAnalysis) + } + + @Test + fun `나머지 필드가 성해도 감정 분석만 빠지면 화면 전체가 실패하지 않는다`() { + // 필수로 두던 시절에는 여기서 MissingFieldException 이 나 탭 전체가 ErrorBox 였고, + // UiText.DynamicOrResource 가 e.message 를 우선해 영문 예외 원문까지 노출됐다. + val body = + """ + { "status": 200, "code": 200, "data": { + "week": [], "emotions": [], "summaryText": "이번 주 요약", + "dailyQuestionAmount": 2, "diaryAmount": 1 } } + """.trimIndent() + + val report = + json + .decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) + .data!! + .toDomain() + + assertEquals("이번 주 요약", report.summaryText) + assertEquals(2, report.dailyQuestionAmount) } } diff --git a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt index a9f826f5a7..743e0cfa09 100644 --- a/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt +++ b/feature/mindrecord/domain/src/main/kotlin/com/afternote/feature/mindrecord/domain/model/WeeklyReportModels.kt @@ -7,7 +7,8 @@ data class WeeklyReport( val week: List, val dailyQuestions: List, val emotions: List, - val emotionAnalysis: EmotionAnalysis, + /** 서버가 주지 않으면 null — 상태는 [EmotionAnalysisStatus.UNKNOWN] 이 된다 (#725). */ + val emotionAnalysis: EmotionAnalysis?, ) /** @@ -49,6 +50,14 @@ enum class EmotionAnalysisStatus { /** 분석이 끝났다. 키워드가 0건이면 실제로 0건인 것이다. */ COMPLETED, + + /** + * 서버가 진행 상태를 주지 않았다 — 0건인지 분석 중인지 알 수 없다. + * + * 계약상 오지 않을 자리지만, 그렇다고 화면 전체를 오류로 만들 이유는 없다. 확정만 + * 하지 않으면 되므로 카드 한 장이 «모른다» 를 표시하고 나머지는 그대로 그린다. + */ + UNKNOWN, } data class WeeklyReportDay( diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 76d0c5e121..c3425180c5 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -7,6 +7,8 @@ plugins { android { namespace = "com.afternote.feature.mindrecord.presentation" resourcePrefix = "mindrecord_" + + testOptions.unitTests.isIncludeAndroidResources = true } dependencies { @@ -19,4 +21,9 @@ dependencies { implementation(libs.compose.rich.editor) testImplementation(libs.coroutines.test) + + // 부분 성공에서 어떤 문구가 나가는지는 컴포지션을 태워야 확인된다 (#725). + 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/component/EmotionKeywordCard.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/EmotionKeywordCard.kt index 39929f0527..caa7b84161 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/EmotionKeywordCard.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/component/EmotionKeywordCard.kt @@ -22,9 +22,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.afternote.core.ui.modifierextention.shimmerLoadingPlaceholder import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus @@ -85,7 +88,18 @@ fun EmotionKeywordCard( .height(BUBBLE_AREA_HEIGHT), ) { if (capped.isEmpty()) { - if (confirmsEmptyCount) EmptyBubble() else PendingBubble() + when { + // 분석이 끝났고 실제로 0건 — "0" 을 찍어 확정한다. + confirmsEmptyCount -> EmptyBubble() + + // 실패는 대기와 같은 그림이면 안 된다. 종전에는 둘 다 PendingBubble 로 + // 떨어져 시각적으로 구분되지 않았고 TalkBack 은 아무것도 읽지 않았다. + analysisStatus == EmotionAnalysisStatus.FAILED -> FailedBubble() + + analysisStatus == EmotionAnalysisStatus.UNKNOWN -> UnknownBubble() + + else -> PendingBubble() + } } else { val slots = slotsFor(capped.size) capped.forEachIndexed { index, keyword -> @@ -148,22 +162,36 @@ private fun Bubble( } /** - * 분석이 끝나지 않았을 때의 자리 표시. [EmptyBubble] 과 크기·위치는 같지만 **"0" 을 찍지 - * 않는다** — 키워드가 없다고 확정하는 표시이기 때문이다 (#725). + * 분석이 끝나지 않았을 때의 자리 표시. 아직 값이 오는 중이라는 것을 shimmer 로 알리고 + * **"0" 을 찍지 않는다** — 그건 키워드가 없다고 확정하는 표시다 (#725). */ @Composable private fun PendingBubble() { - val slot = EMPTY_SLOT - Box( - modifier = - Modifier - .offset(x = slot.offsetX, y = slot.offsetY) - .size(slot.size) - .clip(CircleShape) - .background(slot.color.copy(alpha = 0.3f)), + PlaceholderBubble( + contentDescription = stringResource(R.string.mindrecord_emotion_card_pending_bubble_label), + isLoading = true, + ) +} + +/** 분석이 실패했을 때의 자리 표시. 대기와 같은 그림이면 두 상태가 구분되지 않는다. */ +@Composable +private fun FailedBubble() { + PlaceholderBubble( + contentDescription = stringResource(R.string.mindrecord_emotion_card_failed_bubble_label), + isLoading = false, ) } +/** 서버가 진행 상태를 주지 않았을 때. 실패도 대기도 아니므로 그 둘과 문구를 달리한다. */ +@Composable +private fun UnknownBubble() { + PlaceholderBubble( + contentDescription = stringResource(R.string.mindrecord_emotion_card_unknown_bubble_label), + isLoading = false, + ) +} + +/** 분석이 끝났고 실제로 키워드가 0건. 이때만 "0" 을 찍어 확정한다. */ @Composable private fun EmptyBubble() { val slot = EMPTY_SLOT @@ -184,6 +212,28 @@ private fun EmptyBubble() { } } +/** + * 값이 아직/영영 없을 때의 빈 버블. 빈 `Box` 만 두면 TalkBack 이 아무것도 읽지 않아 + * 화면에 무슨 일이 있는지 알 수 없다. + */ +@Composable +private fun PlaceholderBubble( + contentDescription: String, + isLoading: Boolean, +) { + val slot = EMPTY_SLOT + Box( + modifier = + Modifier + .offset(x = slot.offsetX, y = slot.offsetY) + .size(slot.size) + .clip(CircleShape) + .background(slot.color.copy(alpha = 0.3f)) + .then(if (isLoading) Modifier.shimmerLoadingPlaceholder() else Modifier) + .semantics { this.contentDescription = contentDescription }, + ) +} + // ── Slot ────────────────────────────────────────────────────────────────────── private data class BubbleSlot( diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt index 25ae86fa73..c7027bdd4f 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/WeeklyReportScreen.kt @@ -197,25 +197,44 @@ private fun ErrorBox( * 키워드가 없을 때 무엇을 적을지는 **분석 상태**가 정한다. 종전에는 빈 목록이면 무조건 * "키워드가 나오지 않았어요" 를 적어, 분석 대기·실패까지 정상 빈 상태로 확정했다 (#725). * - * 키워드가 있으면 서버 요약을 그대로 쓴다 — 분석이 끝난 뒤에만 도달하는 자리다. + * **상태를 키워드보다 먼저 본다.** 부분 성공(일부 완료 + 일부 대기)에서는 완료분 키워드가 + * `emotions` 에 실려 내려오므로(BE `buildTopEmotions` 에 완료 게이트가 없다), 키워드 유무를 + * 먼저 보면 아직 분석 중인데도 폴백 요약이 최종 요약처럼 확정된다. + * + * 그래서 대기 중에는 키워드가 이미 몇 개 있어도 «분석 중» 임을 알린다 — 지금 보이는 것이 + * 전부가 아니라는 사실이 요약 문구보다 중요하다. */ @Composable -private fun emotionCardDescription(state: WeeklyReportUiState.Success): String = - when { - state.emotionKeywords.isNotEmpty() -> { - state.summaryText +internal fun emotionCardDescription(state: WeeklyReportUiState.Success): String = + when (state.emotionAnalysisStatus) { + EmotionAnalysisStatus.PENDING -> { + if (state.emotionKeywords.isEmpty()) { + stringResource(R.string.mindrecord_emotion_card_pending_description) + } else { + // 일부는 이미 나왔다 — 이 요약이 최종이 아니라는 것만 덧붙인다. + stringResource(R.string.mindrecord_weekly_report_summary_pending) + } } - state.emotionAnalysisStatus == EmotionAnalysisStatus.PENDING -> { - stringResource(R.string.mindrecord_emotion_card_pending_description) + EmotionAnalysisStatus.FAILED -> { + stringResource(R.string.mindrecord_emotion_card_failed_description) } - state.emotionAnalysisStatus == EmotionAnalysisStatus.FAILED -> { - stringResource(R.string.mindrecord_emotion_card_failed_description) + EmotionAnalysisStatus.UNKNOWN -> { + // 0 건인지 분석 중인지 모른다 — 어느 쪽으로도 확정하지 않는다. + if (state.emotionKeywords.isEmpty()) { + stringResource(R.string.mindrecord_emotion_card_unknown_description) + } else { + state.summaryText + } } else -> { - stringResource(R.string.mindrecord_emotion_card_empty_description, state.userName) + if (state.emotionKeywords.isNotEmpty()) { + state.summaryText + } else { + stringResource(R.string.mindrecord_emotion_card_empty_description, state.userName) + } } } 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 959e09a4af..0bd9dcb7c1 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 @@ -72,8 +72,11 @@ class WeeklyReportViewModel * 소관이다. 보고 있던 주를 그대로 다시 불러 상태를 갱신한다 (#725). */ fun retryEmotionAnalysis() { - val current = (internalState.value.loadPhase as? LoadPhase.Loaded)?.monday ?: return - load(current, showsLoading = false, keepsStateOnFailure = true) + // 재조회 정책(로딩·실패 유지·중복 가드)이 한 곳에만 있도록 위임한다. + // 다만 이쪽은 **사용자가 누른** 갱신이라 진행 표시를 낸다 — BE#117 전까지는 + // 재조회가 같은 FAILED 를 돌려주는 것이 기본 경로이고, 그러면 데이터가 같아 + // StateFlow 가 방출조차 하지 않아 눌러도 픽셀이 안 바뀐다. + refreshOnReturn(showsLoading = true) } /** @@ -82,13 +85,13 @@ class WeeklyReportViewModel * 화면이 살아 있는 채로 발화하므로 로딩을 방출하지 않고, 실패해도 보고 있던 * 화면을 유지한다. 보고 있던 주를 그대로 다시 조회한다. */ - fun refreshOnReturn() { + fun refreshOnReturn(showsLoading: Boolean = false) { // 진입 직후의 ON_RESUME 은 init 로드와 겹친다 — 진행 중이면 건너뛴다. if (loadJob?.isActive == true) return val current = (internalState.value.loadPhase as? LoadPhase.Loaded)?.monday ?: weekOptions.first().monday - load(current, showsLoading = false, keepsStateOnFailure = true) + load(current, showsLoading = showsLoading, keepsStateOnFailure = true) } private fun load( @@ -123,7 +126,7 @@ class WeeklyReportViewModel internalState.update { it.copy(loadPhase = LoadPhase.Loaded(monday, report, profile.name)) } - awaitEmotionAnalysis(monday, report.emotionAnalysis.status) + awaitEmotionAnalysis(monday, report.analysisStatus) }.onFailure { e -> internalState.update { current -> if (keepsStateOnFailure && current.loadPhase is LoadPhase.Loaded) { @@ -159,6 +162,10 @@ class WeeklyReportViewModel monday: LocalDate, initialStatus: EmotionAnalysisStatus, ) { + // 폴링 근거는 "저장 직후 비동기 분석" 이라 이번 주에만 성립한다. 지난 주를 + // 골라 보는 동안 8초마다 조회가 나갈 이유가 없다. + if (monday != LocalDate.now().with(DayOfWeek.MONDAY)) return + var status = initialStatus repeat(EMOTION_ANALYSIS_POLL_ATTEMPTS) { if (status != EmotionAnalysisStatus.PENDING) return @@ -166,9 +173,13 @@ class WeeklyReportViewModel val report = repository .getWeeklyReport(date = monday.format(API_DATE_FORMATTER)) - .getOrNull() ?: return + .getOrNull() currentCoroutineContext().ensureActive() - status = report.emotionAnalysis.status + // 한 번 실패했다고 남은 시도를 전부 버리지 않는다 — PENDING 화면에는 + // 재시도 수단이 없어(카드는 FAILED 전용) 화면에 머무는 동안 복구할 길이 + // 사라진다. 이번 시도만 소모하고 다음 간격을 기다린다. + if (report == null) return@repeat + status = report.analysisStatus internalState.update { current -> val phase = current.loadPhase // 그 사이 다른 주로 옮겨갔으면 덮어쓰지 않는다. @@ -272,7 +283,7 @@ class WeeklyReportViewModel report.dailyQuestionAmount to MindRecordCategoryUi.DailyQuestion, report.diaryAmount to MindRecordCategoryUi.Diary, ), - emotionAnalysisStatus = report.emotionAnalysis.status, + emotionAnalysisStatus = report.analysisStatus, weekDays = mapWeekDays(monday, report.week), emotionKeywords = mapEmotionKeywords(report.emotions), summaryText = report.summaryText, @@ -333,3 +344,7 @@ class WeeklyReportViewModel } } } + +/** 서버가 진행 상태를 주지 않았으면 «모른다» — 0 건으로 확정하지 않는다 (#725). */ +private val WeeklyReport.analysisStatus: EmotionAnalysisStatus + get() = emotionAnalysis?.status ?: EmotionAnalysisStatus.UNKNOWN diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index 2d0e1c4154..70c0c761d4 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -96,10 +96,16 @@ 이번 주 박서연 님의 기록에서는\n\'가족\'을 위한 \'감사\'의 마음이 엿보입니다. 이번 주 %1$s 님의 기록에서는 키워드가 나오지 않았어요. + 감정 분석 상태를 알 수 없어요. + 감정 분석 상태를 확인하지 못했어요. 잠시 뒤 다시 확인해 주세요. + 감정 키워드를 분석하고 있어요. + 감정 분석에 실패해 키워드를 불러오지 못했어요. 기록을 분석하고 있어요. 잠시 뒤 이 자리에 키워드가 채워집니다. - 감정 분석에 실패했어요. 다시 시도해 주세요. - 다시 시도 + 감정 분석에 실패했어요. 잠시 뒤 다시 확인해 주세요. + + 다시 확인 분석 중 임시 안내예요. 분석이 끝나면 이번 주 요약으로 바뀝니다. diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/EmotionCardDescriptionTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/EmotionCardDescriptionTest.kt new file mode 100644 index 0000000000..04f76e0c99 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/EmotionCardDescriptionTest.kt @@ -0,0 +1,89 @@ +package com.afternote.feature.mindrecord.presentation.screen.sender + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus +import com.afternote.feature.mindrecord.presentation.model.EmotionKeyword +import com.afternote.feature.mindrecord.presentation.viewmodel.WeeklyReportUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate + +/** + * 감정 카드 본문 문구가 **상태를 키워드보다 먼저 보는지** (#725 리뷰 지적). + * + * 부분 성공(일부 완료 + 일부 대기)에서는 완료분 키워드가 `emotions` 에 실려 내려온다 — + * BE `buildTopEmotions` 에 완료 게이트가 없다. 키워드 유무를 먼저 보면 아직 분석 중인데도 + * 폴백 요약이 최종 요약처럼 확정되고, 대기 분기는 도달 불가가 된다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class EmotionCardDescriptionTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun state( + status: EmotionAnalysisStatus, + keywords: List = emptyList(), + ) = WeeklyReportUiState.Success( + selectedMonday = LocalDate.of(2026, 8, 24), + weekOptions = emptyList(), + dateRange = "", + userName = "아담", + recordedDays = 1, + counts = emptyList(), + emotionAnalysisStatus = status, + weekDays = emptyList(), + emotionKeywords = keywords, + summaryText = "이번 주 기록을 바탕으로 인사이트를 준비 중이에요.", + dailyQuestions = emptyList(), + ) + + private fun describe(state: WeeklyReportUiState.Success): String { + lateinit var text: String + composeRule.setContent { + AfternoteTheme { text = emotionCardDescription(state) } + } + composeRule.waitForIdle() + return text + } + + @Test + fun `키워드가 이미 나왔어도 대기 중이면 최종 요약으로 확정하지 않는다`() { + val described = + describe( + state( + status = EmotionAnalysisStatus.PENDING, + keywords = listOf(EmotionKeyword(keyword = "가족", count = 60)), + ), + ) + + assert(described != "이번 주 기록을 바탕으로 인사이트를 준비 중이에요.") { + "부분 성공에서 폴백 요약이 최종 요약처럼 확정됐다: $described" + } + } + + @Test + fun `분석이 끝나야 서버 요약을 쓴다`() { + val described = + describe( + state( + status = EmotionAnalysisStatus.COMPLETED, + keywords = listOf(EmotionKeyword(keyword = "가족", count = 60)), + ), + ) + + assert(described == "이번 주 기록을 바탕으로 인사이트를 준비 중이에요.") { described } + } + + @Test + fun `상태를 모르면 키워드 0건이라고 확정하지 않는다`() { + val described = describe(state(status = EmotionAnalysisStatus.UNKNOWN)) + + assert(!described.contains("나오지 않았")) { described } + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt index 561a7a99ae..210cfdf64d 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt @@ -138,8 +138,8 @@ class WeeklyReportEmotionAnalysisTest { runTest(dispatcher) { // 실측처럼 계속 pending 인 경우 — 폴링이 멈추고 화면은 대기 상태로 남는다. var calls = 0 - val viewModel = - viewModel(generateSequence { report(total = 1, pending = 1) }.take(50).toList()) { calls++ } + // fake 는 마지막 응답을 반복하므로 한 건이면 충분하다. + val viewModel = viewModel(listOf(report(total = 1, pending = 1))) { calls++ } collectUiState(viewModel) advanceUntilIdle() val callsAfterPolling = calls @@ -148,7 +148,34 @@ class WeeklyReportEmotionAnalysisTest { advanceUntilIdle() assertEquals("폴링이 끝난 뒤에는 더 조회하지 않는다", callsAfterPolling, calls) - assertTrue("최초 조회 + 제한된 재조회만 있어야 한다", calls in 2..12) + // 결정적인 값이다 — 최초 1회 + 폴링 8회. 범위로 두면 폴링을 3회로 줄이거나 + // 11회로 늘려도 통과해 회귀를 못 잡는다. + assertEquals("최초 조회 1회 + 폴링 8회", 9, calls) + } + + @Test + fun `성공분 키워드가 이미 있어도 대기 중이면 대기로 알린다`() = + runTest(dispatcher) { + // 부분 성공에서는 완료분 키워드가 emotions 에 실려 내려온다(BE buildTopEmotions 에 + // 완료 게이트 없음). 키워드 유무를 상태보다 먼저 보면 폴백 요약이 최종처럼 확정된다. + val viewModel = + viewModel( + listOf( + report( + total = 2, + succeeded = 1, + pending = 1, + emotions = listOf(WeeklyReportEmotion(keyword = "가족", percentage = 60)), + ), + ), + ) {} + collectUiState(viewModel) + advanceUntilIdle() + + val state = viewModel.uiState.value as WeeklyReportUiState.Success + + assertTrue("성공분 키워드는 그대로 보여준다", state.emotionKeywords.isNotEmpty()) + assertEquals(EmotionAnalysisStatus.PENDING, state.emotionAnalysisStatus) } // ── 테스트 도구 ───────────────────────────────────────────────────────────