Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -239,6 +240,8 @@ class MindRecordLifecycleAndroidTest {
WeeklyReportDailyQuestion("다른 날 질문", "답변 B", friday.toString()),
),
emotions = emptyList(),
// 이 테스트들은 분석 상태를 보지 않는다 — 완료로 고정한다 (#725).
emotionAnalysis = EmotionAnalysis(total = 0, succeeded = 0, pending = 0, failed = 0),
),
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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),
)
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,33 @@ data class WeeklyReportDto(
// 서버 JSON 키가 kebab-case (`daily-question`).
@SerialName("daily-question") val dailyQuestions: List<WeeklyReportDailyQuestionDto>,
@SerialName("emotions") val emotions: List<WeeklyReportEmotionDto>,
// 보정형 기본값을 두지 않는다 — 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,
)

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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`·미래 종류)는 일기가 아니다. */
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,59 @@ data class WeeklyReport(
val week: List<WeeklyReportDay>,
val dailyQuestions: List<WeeklyReportDailyQuestion>,
val emotions: List<WeeklyReportEmotion>,
/** 서버가 주지 않으면 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,
Expand Down
1 change: 1 addition & 0 deletions feature/mindrecord/presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading