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 777f6c7457..1c32100817 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 @@ -239,6 +240,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 3fde0c2765..8ff8e85548 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 @@ -947,6 +948,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 = @@ -977,4 +980,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 6e9cb36337..966de88ff0 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 @@ -16,6 +16,33 @@ data class WeeklyReportDto( // 서버 JSON 키가 kebab-case (`daily-question`). @SerialName("daily-question") val dailyQuestions: List, @SerialName("emotions") val emotions: List, + // 보정형 기본값을 두지 않는다 — 0 으로 접으면 "분석 대기" 와 "분석할 것이 없음" 이 다시 + // 구분 불가능해져 이 이슈가 고치려는 상태 소실이 그대로 재현된다 (#725). + // + // 다만 **필수로 두지도 않는다.** 필수면 이 필드 하나가 빠졌을 때 MissingFieldException 이 + // 나 주간리포트 탭 전체가 오류 화면이 되고, 예외 원문까지 노출된다. null 은 0 이 아니라 + // "모른다"(UNKNOWN) 로 옮겨지므로 확정하지 않는다는 목적은 그대로 지키면서 실패 반경이 + // 카드 한 장으로 줄어든다. + @SerialName("emotionAnalysis") val emotionAnalysis: EmotionAnalysisSummaryDto? = null, +) + +/** + * 그 주 감정 분석 진행 상태 (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 95841dc007..c982432b69 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`·미래 종류)는 일기가 아니다. */ @@ -52,6 +55,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..17e369eb4c --- /dev/null +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/dto/EmotionAnalysisContractTest.kt @@ -0,0 +1,130 @@ +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.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * 감정 분석 상태 계약 가드 (#725, OpenAPI `EmotionAnalysisSummary`). + * + * `emotions` 는 **분석 성공분만** 담기므로, 빈 배열 하나로는 "분석이 끝났는데 키워드가 + * 없음" 과 "아직 분석 중" 과 "분석 실패" 가 구분되지 않는다. 그 셋을 가르는 값이 + * `emotionAnalysis` 라, 이 필드가 소실되면 이슈가 고치려는 오표시가 그대로 재현된다. + * + * Json 은 프로덕션 설정을 **복제하지 않고 그대로 가져다 쓴다** — 복제하면 앱 설정이 + * 바뀔 때 이 테스트만 조용히 어긋난다. + */ +@OptIn(ExperimentalSerializationApi::class) +class EmotionAnalysisContractTest { + private val json = NetworkModule.provideJson() + + private fun decodeStatus(analysisJson: String): EmotionAnalysisStatus? { + val body = + """ + { "status": 200, "code": 200, "data": { + "dailyQuestionAmount": 0, "diaryAmount": 0, "summaryText": "", + "daily-question": [], "week": [], "emotions": [], + "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 키가 빠져도 0 건으로 접히지 않는다`() { + // 보정형 기본값을 두면 "분석 대기" 가 "분석할 것 없음" 으로 접혀 이 이슈가 재현된다. + // 그렇다고 필수로 두면 이 필드 하나에 주간리포트 탭 전체가 오류 화면이 되므로, + // null 로 받아 «모른다» 로 옮긴다 — 확정하지 않는다는 목적은 그대로다. + val body = + """ + { "status": 200, "code": 200, "data": { + "dailyQuestionAmount": 0, "diaryAmount": 0, "summaryText": "", + "daily-question": [], "week": [], "emotions": [] } } + """.trimIndent() + + 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": { + "dailyQuestionAmount": 2, "diaryAmount": 1, "summaryText": "이번 주 요약", + "daily-question": [], "week": [], "emotions": [] } } + """.trimIndent() + + val report = + json + .decodeFromString(BaseResponse.serializer(WeeklyReportDto.serializer()), body) + .data!! + .toDomain() + + assertEquals("이번 주 요약", report.summaryText) + assertEquals(2, report.dailyQuestionAmount) + } +} 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 8008ae7544..6d53e5c34e 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 @@ -40,7 +40,8 @@ class WeeklyReportContractTest { """ { "status": 200, "code": 200, "data": { "dailyQuestionAmount": 0, "diaryAmount": 0, "summaryText": "", - "daily-question": [], "emotions": [], "week": $weekJson } } + "daily-question": [], "emotions": [], "week": $weekJson, + "emotionAnalysis": { "total": 1, "succeeded": 1, "pending": 0, "failed": 0 } } } """.trimIndent() return 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 6155d128a7..ac4cf83a9e 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,59 @@ data class WeeklyReport( val week: List, val dailyQuestions: List, val emotions: List, + /** 서버가 주지 않으면 null — 상태는 [EmotionAnalysisStatus.UNKNOWN] 이 된다 (#725). */ + 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, + + /** + * 서버가 진행 상태를 주지 않았다 — 0건인지 분석 중인지 알 수 없다. + * + * 계약상 오지 않을 자리지만, 그렇다고 화면 전체를 오류로 만들 이유는 없다. 확정만 + * 하지 않으면 되므로 카드 한 장이 «모른다» 를 표시하고 나머지는 그대로 그린다. + */ + UNKNOWN, +} + data class WeeklyReportDay( val diaryId: Long, val day: Int, diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 9d593b9c83..4b42a8bde8 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { // 컴파일된 리소스로 문구를 검증한다 — aapt2 의 앞뒤 공백 제거는 소스 XML 만 봐서는 잡히지 않는다 (#732). // 열린 주차 메뉴의 스크롤·최하단 선택도 JVM 에서 그대로 재현한다 (#729). // 매퍼가 android.util.Log 를 타 JVM 단위 테스트로는 돌지 않는다 (#751). + // 부분 성공에서 어떤 문구가 나가는지도 컴포지션을 태워야 확인된다 (#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 8304d781ac..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 @@ -15,17 +15,22 @@ 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 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 import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.model.EmotionKeyword @@ -40,15 +45,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 +88,18 @@ fun EmotionKeywordCard( .height(BUBBLE_AREA_HEIGHT), ) { if (capped.isEmpty()) { - EmptyBubble() + 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 -> @@ -88,6 +113,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 +161,37 @@ private fun Bubble( } } +/** + * 분석이 끝나지 않았을 때의 자리 표시. 아직 값이 오는 중이라는 것을 shimmer 로 알리고 + * **"0" 을 찍지 않는다** — 그건 키워드가 없다고 확정하는 표시다 (#725). + */ +@Composable +private fun PendingBubble() { + 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 @@ -146,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( @@ -218,6 +306,7 @@ private fun EmotionKeywordCardPreview4() { EmotionKeyword("그리움", 8), ), descriptionText = "이번 주 박서연 님의 기록에서는 '가족'을 위한 '감사'의 마음이 엿보입니다.", + analysisStatus = EmotionAnalysisStatus.COMPLETED, ) } } @@ -229,6 +318,7 @@ private fun EmotionKeywordCardPreview1() { EmotionKeywordCard( keywords = listOf(EmotionKeyword("가족", 8)), descriptionText = "이번 주 박서연 님의 기록에서는 '가족'의 마음이 엿보입니다.", + analysisStatus = EmotionAnalysisStatus.COMPLETED, ) } } @@ -240,6 +330,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 e1ef754025..69568b46c6 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, ) } } @@ -207,6 +207,53 @@ private fun ErrorBox( } } +/** + * 감정 카드 본문 문구. + * + * 키워드가 없을 때 무엇을 적을지는 **분석 상태**가 정한다. 종전에는 빈 목록이면 무조건 + * "키워드가 나오지 않았어요" 를 적어, 분석 대기·실패까지 정상 빈 상태로 확정했다 (#725). + * + * **상태를 키워드보다 먼저 본다.** 부분 성공(일부 완료 + 일부 대기)에서는 완료분 키워드가 + * `emotions` 에 실려 내려오므로(BE `buildTopEmotions` 에 완료 게이트가 없다), 키워드 유무를 + * 먼저 보면 아직 분석 중인데도 폴백 요약이 최종 요약처럼 확정된다. + * + * 그래서 대기 중에는 키워드가 이미 몇 개 있어도 «분석 중» 임을 알린다 — 지금 보이는 것이 + * 전부가 아니라는 사실이 요약 문구보다 중요하다. + */ +@Composable +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) + } + } + + 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 -> { + if (state.emotionKeywords.isNotEmpty()) { + state.summaryText + } else { + stringResource(R.string.mindrecord_emotion_card_empty_description, state.userName) + } + } + } + @Preview(showBackground = true) @Composable private fun WeeklyReportScreenPreview() { @@ -222,10 +269,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 3de7bc5a33..b4453583ee 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 @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.afternote.core.common.result.runCatchingCancellable 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 @@ -23,6 +24,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 @@ -31,6 +34,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.DayOfWeek import java.time.LocalDate import java.time.format.DateTimeFormatter import javax.inject.Inject @@ -63,19 +67,33 @@ class WeeklyReportViewModel fun selectWeek(monday: LocalDate) = load(monday) + /** + * 감정 분석 실패 상태에서 사용자가 누르는 재시도. + * + * FE 가 할 수 있는 것은 **재조회**뿐이다 — 서버 측 재분석 트리거는 Afternote-BE#117 + * 소관이다. 보고 있던 주를 그대로 다시 불러 상태를 갱신한다 (#725). + */ + fun retryEmotionAnalysis() { + // 재조회 정책(로딩·실패 유지·중복 가드)이 한 곳에만 있도록 위임한다. + // 다만 이쪽은 **사용자가 누른** 갱신이라 진행 표시를 낸다 — BE#117 전까지는 + // 재조회가 같은 FAILED 를 돌려주는 것이 기본 경로이고, 그러면 데이터가 같아 + // StateFlow 가 방출조차 하지 않아 눌러도 픽셀이 안 바뀐다. + refreshOnReturn(showsLoading = true) + } + /** * 탭 전환·작성 화면 복귀 등 사용자가 요청하지 않은 자동 갱신. * * 화면이 살아 있는 채로 발화하므로 로딩을 방출하지 않고, 실패해도 보고 있던 * 화면을 유지한다. 보고 있던 주를 그대로 다시 조회한다. */ - 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( @@ -111,6 +129,7 @@ class WeeklyReportViewModel internalState.update { it.copy(loadPhase = LoadPhase.Loaded(monday, report, profile.name)) } + awaitEmotionAnalysis(monday, report.analysisStatus) }.onFailure { e -> internalState.update { current -> if (keepsStateOnFailure && current.loadPhase is LoadPhase.Loaded) { @@ -131,6 +150,61 @@ class WeeklyReportViewModel } } + /** + * 감정 분석이 끝나기를 화면에 머무른 채 기다린다. + * + * 분석은 저장 직후 비동기로 돌고 완료 신호를 주는 채널이 없어서, 탭에 머무르는 동안 + * 결과가 반영되려면 제한된 재조회가 필요하다 (#725). 무한 폴링은 하지 않는다 — + * [EMOTION_ANALYSIS_POLL_ATTEMPTS] 회만 시도하고, 그 뒤에는 화면 이탈·복귀 + * ([refreshOnReturn])나 사용자의 재시도에 맡긴다. + * + * 이 로직은 [load] 의 코루틴 안에서 돈다. 사용자가 다른 주를 고르면 그 `loadJob` + * 취소가 이 대기까지 함께 끊는다. + */ + private suspend fun awaitEmotionAnalysis( + 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 + delay(EMOTION_ANALYSIS_POLL_INTERVAL_MILLIS) + val report = + repository + .getWeeklyReport(date = monday.format(API_DATE_FORMATTER)) + .getOrNull() + currentCoroutineContext().ensureActive() + // 한 번 실패했다고 남은 시도를 전부 버리지 않는다 — PENDING 화면에는 + // 재시도 수단이 없어(카드는 FAILED 전용) 화면에 머무는 동안 복구할 길이 + // 사라진다. 이번 시도만 소모하고 다음 간격을 기다린다. + if (report == null) return@repeat + status = report.analysisStatus + 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, + ): List { + val thisMonday = today.with(DayOfWeek.MONDAY) + return (0 until count).map { weeksAgo -> + WeekOption(monday = thisMonday.minusWeeks(weeksAgo.toLong())) + } + } + /** * 월~일 7칸을 만든다. * @@ -216,6 +290,7 @@ class WeeklyReportViewModel report.dailyQuestionAmount to MindRecordCategoryUi.DailyQuestion, report.diaryAmount to MindRecordCategoryUi.Diary, ), + emotionAnalysisStatus = report.analysisStatus, weekDays = mapWeekDays(monday, report.week), emotionKeywords = mapEmotionKeywords(report.emotions), summaryText = report.summaryText, @@ -224,6 +299,13 @@ 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.") @@ -281,3 +363,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 5907be05c9..376282284c 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -105,6 +105,19 @@ 나의 감정 키워드 이번 주 박서연 님의 기록에서는\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 new file mode 100644 index 0000000000..210cfdf64d --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt @@ -0,0 +1,237 @@ +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 + // fake 는 마지막 응답을 반복하므로 한 건이면 충분하다. + val viewModel = viewModel(listOf(report(total = 1, pending = 1))) { calls++ } + collectUiState(viewModel) + advanceUntilIdle() + val callsAfterPolling = calls + + advanceTimeBy(10 * 60 * 1000L) + advanceUntilIdle() + + assertEquals("폴링이 끝난 뒤에는 더 조회하지 않는다", callsAfterPolling, calls) + // 결정적인 값이다 — 최초 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) + } + + // ── 테스트 도구 ─────────────────────────────────────────────────────────── + + /** + * `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 +}