From 5a42cf1489be107096c4ad795c2ba697929b48be Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sun, 23 Aug 2026 22:27:19 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor(mindrecord):=20=EC=A3=BC=EA=B0=84?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EB=82=A0=EC=A7=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=EC=9D=84=20data=20=EA=B3=84=EC=B8=B5=20mapper=20?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AE=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 응답 포맷 해석("yyyy.MM.dd 요일" 또는 ISO)과 주간 범위 검증이 presentation 에 있었다. `WeeklyReportViewModel` 이 와이어 포맷을 알고 직접 파싱하는 구조라, 도메인 소비자가 늘 때마다 같은 파싱을 다시 구현해야 했다. DTO→도메인 경계에서 `LocalDate` 로 변환해 올린다. 해석하지 못한 항목은 그 경계에서 제외한다. 종전에는 같은 목록을 두고 집계 경로는 버리고 표시 경로는 `LocalDate.now()` 로 메워, 파싱 못 한 기록이 오늘 작성한 것처럼 HISTORY 카드에 앉았다 — 로그도 에러 표시도 없는 조용한 오표시였다. 주간 범위 방어도 한 번만 건다. 종전에는 집계만 범위를 걸고 HISTORY 는 전량을 렌더해, 범위가 적용된 수치와 적용되지 않은 목록이 한 화면에 공존했다. Closes #547 --- feature/mindrecord/data/build.gradle.kts | 3 + .../data/mapper/ServerDateParser.kt | 32 +++++ .../data/mapper/WeeklyReportMapper.kt | 25 +++- .../data/mapper/WeeklyReportDateMapperTest.kt | 63 ++++++++ .../domain/model/WeeklyReportModels.kt | 11 +- .../viewmodel/WeeklyReportViewModel.kt | 26 +--- .../viewmodel/WeeklyReportWeekRangeTest.kt | 136 ++++++++++++++++++ 7 files changed, 272 insertions(+), 24 deletions(-) create mode 100644 feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/ServerDateParser.kt create mode 100644 feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/mapper/WeeklyReportDateMapperTest.kt create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt diff --git a/feature/mindrecord/data/build.gradle.kts b/feature/mindrecord/data/build.gradle.kts index 0565949d80..66cd8fadce 100644 --- a/feature/mindrecord/data/build.gradle.kts +++ b/feature/mindrecord/data/build.gradle.kts @@ -4,6 +4,9 @@ plugins { android { namespace = "com.afternote.feature.mindrecord.data" + + // 매퍼가 해석 실패를 android.util.Log 로 남긴다 — 순수 JUnit 에서 그 호출이 죽지 않게 한다. + testOptions.unitTests.isReturnDefaultValues = true } dependencies { diff --git a/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/ServerDateParser.kt b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/ServerDateParser.kt new file mode 100644 index 0000000000..210e902577 --- /dev/null +++ b/feature/mindrecord/data/src/main/kotlin/com/afternote/feature/mindrecord/data/mapper/ServerDateParser.kt @@ -0,0 +1,32 @@ +package com.afternote.feature.mindrecord.data.mapper + +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** + * 서버가 내려주는 날짜 문자열을 [LocalDate] 로 해석한다 (해석 못 하면 null). + * + * 응답 포맷 지식을 **DTO→도메인 경계에 둔다.** 종전에는 `WeeklyReportViewModel` 이 + * "서버가 `yyyy.MM.dd 요일` 또는 ISO 로 내려준다" 는 사실을 알고 직접 파싱했다 — + * presentation 이 와이어 포맷을 아는 구조라, 도메인 소비자가 늘 때마다 같은 파싱을 + * 다시 구현해야 했다 (#547). + * + * 세 포맷을 모두 허용한다. `ISO_DATE` 만 두면 시각이 붙은 값(`2026-03-21T20:13:42`)에서 + * 뒤가 남아 파싱이 실패한다. 요일이 붙은 값(`2026.05.22 금`)은 앞 조각만 쓴다. + */ +internal fun parseServerDateOrNull(raw: String): LocalDate? { + val datePart = raw.substringBefore(' ').trim() + if (datePart.isEmpty()) return null + for (formatter in SERVER_DATE_FORMATTERS) { + val parsed = runCatching { LocalDate.parse(datePart, formatter) }.getOrNull() + if (parsed != null) return parsed + } + return null +} + +private val SERVER_DATE_FORMATTERS: List = + listOf( + DateTimeFormatter.ofPattern("yyyy.MM.dd"), + DateTimeFormatter.ISO_DATE, + DateTimeFormatter.ISO_DATE_TIME, + ) 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..4c265198c2 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,5 +1,6 @@ package com.afternote.feature.mindrecord.data.mapper +import android.util.Log import com.afternote.feature.mindrecord.data.dto.EmotionAnalysisSummaryDto import com.afternote.feature.mindrecord.data.dto.WeeklyReportDailyQuestionDto import com.afternote.feature.mindrecord.data.dto.WeeklyReportDayDto @@ -17,7 +18,9 @@ fun WeeklyReportDto.toDomain(): WeeklyReport = diaryAmount = diaryAmount, summaryText = summaryText, week = week.map { it.toDomain() }, - dailyQuestions = dailyQuestions.map { it.toDomain() }, + // 날짜를 해석하지 못한 항목은 여기서 뺀다. 종전에는 집계 경로가 버리고 표시 경로가 + // `LocalDate.now()` 로 메워, 같은 목록을 두고 한쪽은 제외·한쪽은 왜곡이었다 (#547). + dailyQuestions = dailyQuestions.mapNotNull { it.toDomainOrNull() }, emotions = emotions.map { it.toDomain() }, emotionAnalysis = emotionAnalysis.toDomain(), ) @@ -39,12 +42,26 @@ fun WeeklyReportDayDto.toDomain(): WeeklyReportDay = emotion = emotion?.toDomain(), ) -fun WeeklyReportDailyQuestionDto.toDomain(): WeeklyReportDailyQuestion = - WeeklyReportDailyQuestion( +/** + * 날짜를 해석하지 못하면 null 을 돌려 목록에서 제외한다. + * + * 오늘로 메우지 않는다 — 그러면 파싱 못 한 기록이 **오늘 작성한 것처럼** HISTORY 카드에 + * 앉아 필드 이상을 감춘다. 로그도 에러 표시도 없는 조용한 오표시였다 (#547). + */ +fun WeeklyReportDailyQuestionDto.toDomainOrNull(): WeeklyReportDailyQuestion? { + val parsedDate = + parseServerDateOrNull(date) ?: run { + Log.w(TAG, "주간리포트 데일리질문 날짜를 해석하지 못해 목록에서 제외한다: raw=$date") + return null + } + return WeeklyReportDailyQuestion( title = title, content = content, - date = date, + date = parsedDate, ) +} + +private const val TAG = "WeeklyReportMapper" fun EmotionAnalysisSummaryDto.toDomain(): EmotionAnalysis = EmotionAnalysis( diff --git a/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/mapper/WeeklyReportDateMapperTest.kt b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/mapper/WeeklyReportDateMapperTest.kt new file mode 100644 index 0000000000..7dab5c8a54 --- /dev/null +++ b/feature/mindrecord/data/src/test/java/com/afternote/feature/mindrecord/data/mapper/WeeklyReportDateMapperTest.kt @@ -0,0 +1,63 @@ +package com.afternote.feature.mindrecord.data.mapper + +import com.afternote.feature.mindrecord.data.dto.WeeklyReportDailyQuestionDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.LocalDate + +/** + * 서버 날짜 해석과 실패 정책이 DTO→도메인 경계에 있다는 계약 (#547). + * + * 종전에는 이 지식이 `WeeklyReportViewModel` 에 있었고, 실패 처리도 소비처마다 갈렸다 — + * 집계 경로는 버리고 표시 경로는 `LocalDate.now()` 로 메워 파싱 못 한 기록이 **오늘 + * 작성한 것처럼** 카드에 앉았다. 이제 한 곳에서 정한다: **해석 못 하면 제외.** + */ +class WeeklyReportDateMapperTest { + private fun dto(date: String) = WeeklyReportDailyQuestionDto(title = "질문", content = "답변", date = date) + + @Test + fun `요일이 붙은 서버 기본 포맷을 해석한다`() { + // 실서버가 이 형태로 내려준다 (2026-08-23 실측). + assertEquals(LocalDate.of(2026, 8, 23), dto("2026.08.23 일").toDomainOrNull()?.date) + } + + @Test + fun `ISO 날짜도 해석한다`() { + assertEquals(LocalDate.of(2026, 3, 21), dto("2026-03-21").toDomainOrNull()?.date) + } + + @Test + fun `시각이 붙은 ISO 도 해석한다`() { + // ISO_DATE 만 두면 뒤가 남아 실패한다 — 세 포맷을 모두 허용하는 이유다. + assertEquals(LocalDate.of(2026, 3, 25), dto("2026-03-25T20:13:42").toDomainOrNull()?.date) + } + + @Test + fun `해석하지 못한 날짜는 오늘로 메우지 않고 제외한다`() { + // 오늘로 메우면 그 기록이 오늘 작성한 것처럼 HISTORY 카드에 앉아 이상을 감춘다. + assertNull(dto("알 수 없음").toDomainOrNull()) + assertNull(dto("").toDomainOrNull()) + } + + @Test + fun `제외돼도 나머지 항목은 살아남는다`() { + // 실패의 폭이 목록 전체가 되면 안 된다. + val kept = + listOf(dto("2026.08.23 일"), dto("깨진 값"), dto("2026-08-22")) + .mapNotNull { it.toDomainOrNull() } + + assertEquals( + listOf(LocalDate.of(2026, 8, 23), LocalDate.of(2026, 8, 22)), + kept.map { it.date }, + ) + } + + @Test + fun `제목과 본문은 그대로 통과한다`() { + val domain = dto("2026.08.23 일").toDomainOrNull() + + assertEquals("질문", domain?.title) + assertEquals("답변", domain?.content) + } +} 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..1936a0aa88 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 @@ -1,5 +1,7 @@ package com.afternote.feature.mindrecord.domain.model +import java.time.LocalDate + data class WeeklyReport( val dailyQuestionAmount: Int, val diaryAmount: Int, @@ -61,7 +63,14 @@ data class WeeklyReportDay( data class WeeklyReportDailyQuestion( val title: String, val content: String, - val date: String, + /** + * 답변 날짜. **와이어 문자열이 아니라 해석된 값**이다 (#547). + * + * 서버 포맷(`yyyy.MM.dd 요일` / ISO) 해석은 data 계층 mapper 가 맡고, 해석하지 못한 + * 항목은 그 경계에서 제외된다. 그래서 여기까지 올라온 값은 언제나 유효하고, + * 소비처마다 파싱을 다시 구현하거나 실패 폴백을 따로 정할 필요가 없다. + */ + val date: LocalDate, ) data class WeeklyReportEmotion( 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..41854664c0 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 @@ -255,7 +255,10 @@ class WeeklyReportViewModel private fun LoadPhase.Loaded.toSuccessUiState(): WeeklyReportUiState.Success { val sunday = monday.plusDays(WEEK_LENGTH - 1L) - val dailyQuestionDates = report.dailyQuestions.mapNotNull { parseLocalDateOrNull(it.date) } + // 주간 범위 방어를 **한 번만** 한다. 종전에는 집계만 범위를 걸고 HISTORY 는 + // 전량을 렌더해, 같은 화면에 범위가 적용된 수치와 적용되지 않은 목록이 + // 함께 있었다 (#547). + val dailyQuestionsInWeek = report.dailyQuestions.filter { it.date in monday..sunday } return WeeklyReportUiState.Success( selectedMonday = monday, weekOptions = weekOptions, @@ -265,7 +268,7 @@ class WeeklyReportViewModel countRecordedDays( monday = monday, week = report.week, - dailyQuestionDates = dailyQuestionDates, + dailyQuestionDates = dailyQuestionsInWeek.map { it.date }, ), counts = listOf( @@ -276,7 +279,7 @@ class WeeklyReportViewModel weekDays = mapWeekDays(monday, report.week), emotionKeywords = mapEmotionKeywords(report.emotions), summaryText = report.summaryText, - dailyQuestions = report.dailyQuestions.map { it.toUi() }, + dailyQuestions = dailyQuestionsInWeek.map { it.toUi() }, ) } @@ -311,25 +314,10 @@ class WeeklyReportViewModel private fun WeeklyReportDailyQuestion.toUi(): DailyQuestion = DailyQuestion( title = title, - date = parseLocalDate(date), + date = date, content = content, ) // 서버는 "yyyy.MM.dd 요일" 또는 ISO 포맷으로 내려옴 — 둘 다 허용. - private val DATE_FORMATTERS: List = - listOf( - DateTimeFormatter.ofPattern("yyyy.MM.dd"), - DateTimeFormatter.ISO_DATE, - ) - - private fun parseLocalDate(raw: String): LocalDate = parseLocalDateOrNull(raw) ?: LocalDate.now() - - private fun parseLocalDateOrNull(raw: String): LocalDate? { - val datePart = raw.substringBefore(' ').trim() - for (formatter in DATE_FORMATTERS) { - runCatching { return LocalDate.parse(datePart, formatter) } - } - return null - } } } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt new file mode 100644 index 0000000000..7a05fb111d --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt @@ -0,0 +1,136 @@ +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.WeeklyReport +import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion +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.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.Before +import org.junit.Test +import java.lang.reflect.Proxy +import java.time.LocalDate + +/** + * 주간 범위 방어가 **한 곳**에서만 걸린다는 가드 (#547). + * + * 종전에는 집계(`countRecordedDays`)만 범위를 걸고 HISTORY 섹션은 `dailyQuestions` 를 + * 전량 렌더했다. 그래서 범위가 적용된 수치와 적용되지 않은 목록이 한 화면에 공존했다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WeeklyReportWeekRangeTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `조회한 주 밖의 데일리질문은 수치와 목록 양쪽에서 빠진다`() = + runTest(dispatcher) { + // 이번 주(진입 시 기본 선택) 안 1건 + 지난 주 1건 + 다음 주 1건. + val thisMonday = LocalDate.now().with(java.time.DayOfWeek.MONDAY) + val report = + report( + listOf( + dailyQuestion(thisMonday.plusDays(2)), + dailyQuestion(thisMonday.minusDays(3)), + dailyQuestion(thisMonday.plusDays(9)), + ), + ) + + val state = load(report) + + assertEquals("범위 안 1건만 남아야 한다", 1, state.dailyQuestions.size) + assertEquals(thisMonday.plusDays(2), state.dailyQuestions.single().date) + assertEquals("같은 목록을 세므로 수치도 1일이다", 1, state.recordedDays) + } + + @Test + fun `주 경계인 월요일과 일요일은 포함된다`() = + runTest(dispatcher) { + val thisMonday = LocalDate.now().with(java.time.DayOfWeek.MONDAY) + val report = + report(listOf(dailyQuestion(thisMonday), dailyQuestion(thisMonday.plusDays(6)))) + + val state = load(report) + + assertEquals(2, state.dailyQuestions.size) + assertEquals(2, state.recordedDays) + } + + @Test + fun `표시 목록과 집계가 같은 목록을 본다`() = + runTest(dispatcher) { + // 범위 밖 항목이 섞여 있어도 두 값이 어긋나지 않는다. + val thisMonday = LocalDate.now().with(java.time.DayOfWeek.MONDAY) + val report = + report( + listOf( + dailyQuestion(thisMonday.plusDays(1)), + dailyQuestion(thisMonday.plusDays(1)), + dailyQuestion(thisMonday.minusWeeks(2)), + ), + ) + + val state = load(report) + + assertEquals("같은 날 2건은 목록에 2건", 2, state.dailyQuestions.size) + assertEquals("기록일수는 날짜 단위라 1일", 1, state.recordedDays) + } + + // ── 테스트 도구 ─────────────────────────────────────────────────────────── + + private fun dailyQuestion(date: LocalDate) = WeeklyReportDailyQuestion(title = "질문", content = "답변", date = date) + + private fun report(dailyQuestions: List) = + WeeklyReport( + dailyQuestionAmount = dailyQuestions.size, + diaryAmount = 0, + summaryText = "", + week = emptyList(), + dailyQuestions = dailyQuestions, + emotions = emptyList(), + emotionAnalysis = EmotionAnalysis(total = 0, succeeded = 0, pending = 0, failed = 0), + ) + + private suspend fun kotlinx.coroutines.test.TestScope.load(report: WeeklyReport): WeeklyReportUiState.Success { + val repository = + object : WeeklyReportRepository { + override suspend fun getWeeklyReport(date: String): Result = Result.success(report) + } + val viewModel = WeeklyReportViewModel(repository, userRepository()) + backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } + advanceUntilIdle() + return viewModel.uiState.value as WeeklyReportUiState.Success + } + + 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 7c13af45330a6afff6c73128e57863e72407cfef Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sun, 23 Aug 2026 22:38:53 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(mindrecord):=20=EC=A3=BC=EC=B0=A8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20=EB=92=A4=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=ED=99=94=EB=A9=B4=20=EC=95=88=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=ED=95=A0=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주차를 바꾼 조회가 실패하면 화면 전체가 오류 문구 하나로 바뀌어 기존 리포트와 주차 선택 UI 가 함께 사라졌다. 재시도할 수단도, 다른 주차로 옮길 수단도 없었다. 재진입 자동 갱신은 실패한 주차를 잊고 이번 주를 다시 불러, 나갔다 들어와도 사용자가 보려던 주차로 돌아오지 못했다. 실패 상태에 **사용자가 고른 주**와 **직전에 성공한 화면**을 함께 담는다. 직전 화면이 있으면 그것을 유지한 채 배너로만 알리고, 없으면(첫 조회부터 실패) 주차 드롭다운과 재시도를 남긴 오류 화면을 보여준다. 재진입 자동 갱신과 재시도는 모두 실패한 주차를 다시 부른다. 주간리포트 탭의 리스트/캘린더 스위치도 숨긴다. 이 화면에는 두 보기가 없어 눌러도 아무 변화가 없었다 — 같은 탭에서 이미 숨기고 있는 FAB 와 규칙을 맞춘다. Closes #723 --- .../presentation/screen/sender/HomeScreen.kt | 17 +- .../screen/sender/WeeklyReportScreen.kt | 98 ++++++++++- .../viewmodel/WeeklyReportUiState.kt | 21 +++ .../viewmodel/WeeklyReportViewModel.kt | 84 ++++++++- .../src/main/res/values/strings.xml | 3 + .../viewmodel/WeeklyReportRecoveryTest.kt | 160 ++++++++++++++++++ 6 files changed, 367 insertions(+), 16 deletions(-) create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt index ce76864003..12b8d65715 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/HomeScreen.kt @@ -115,12 +115,17 @@ fun HomeScreen( TitleTopBar( title = stringResource(MindRecordR.string.mindrecord_home_title), actions = { - ViewModeSwitcher( - isListView = isListView, - image1 = R.drawable.core_ui_list, - image2 = R.drawable.core_ui_calendar, - onViewChange = { isListView = it }, - ) + // 주간리포트는 리스트/캘린더 두 보기가 없다 — 스위치를 눌러도 화면이 + // 바뀌지 않으니 아예 노출하지 않는다. 컨트롤과 동작을 맞춘다 (#723). + // 같은 이유로 아래 FAB 도 이 탭에서 숨긴다. + if (selectedCategory != MindRecordCategoryUi.WeeklyReport) { + ViewModeSwitcher( + isListView = isListView, + image1 = R.drawable.core_ui_list, + image2 = R.drawable.core_ui_calendar, + onViewChange = { isListView = it }, + ) + } }, ) }, 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..ef2825a073 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -48,7 +49,14 @@ fun WeeklyReportScreen( } is WeeklyReportUiState.Error -> { - ErrorBox(message = state.message.asString(), modifier = modifier) + // 보여 줄 리포트가 없어도 주차 선택과 재시도는 남긴다 — 둘 다 없으면 이 화면에서 + // 빠져나갈 방법이 없다 (#723). + WeeklyReportErrorContent( + state = state, + onWeekSelect = viewModel::selectWeek, + onRetry = viewModel::retry, + modifier = modifier, + ) } is WeeklyReportUiState.Success -> { @@ -56,6 +64,7 @@ fun WeeklyReportScreen( state = state, onWeekSelect = viewModel::selectWeek, onEmotionAnalysisRetry = viewModel::retryEmotionAnalysis, + onRetry = viewModel::retry, modifier = modifier, ) } @@ -67,6 +76,7 @@ private fun WeeklyReportContent( state: WeeklyReportUiState.Success, onWeekSelect: (java.time.LocalDate) -> Unit, onEmotionAnalysisRetry: () -> Unit, + onRetry: () -> Unit, modifier: Modifier = Modifier, ) { // Figma 노드 852:11543 — main 컨테이너의 섹션 간 gap=32, 시작 pt=8, 끝 pb=200. @@ -76,6 +86,15 @@ private fun WeeklyReportContent( contentPadding = PaddingValues(top = 8.dp, bottom = 200.dp), verticalArrangement = Arrangement.spacedBy(32.dp), ) { + state.loadFailure?.let { failure -> + item { + WeekLoadFailureBanner( + failure = failure, + onRetry = onRetry, + ) + } + } + item { WeeklyReportReviewCard( selectedMonday = state.selectedMonday, @@ -191,6 +210,82 @@ private fun ErrorBox( } } +/** + * 조회에 실패했지만 직전 리포트가 남아 있을 때의 배너 (#723). + * + * 어느 주차를 못 불렀는지 밝히고 재시도 수단을 준다. 화면 아래에는 직전에 성공한 + * 리포트가 그대로 남아 있어 다른 주차로 옮길 수도 있다. + */ +@Composable +private fun WeekLoadFailureBanner( + failure: WeeklyReportUiState.LoadFailure, + onRetry: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = + stringResource( + R.string.mindrecord_weekly_report_load_failed, + failure.failedWeekLabel.monthValue, + (failure.failedWeekLabel.dayOfMonth - 1) / 7 + 1, + ), + style = AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + Text( + text = failure.message.asString(), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + TextButton(onClick = onRetry) { + Text( + text = stringResource(R.string.mindrecord_weekly_report_retry), + style = AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + } + } +} + +/** + * 보여 줄 리포트가 하나도 없는 실패 화면 (#723). + * + * 오류 문구만 렌더하면 주차 선택 UI 까지 사라져 이 상태에서 빠져나갈 수 없다. + * 주차 드롭다운과 재시도를 함께 남긴다. + */ +@Composable +private fun WeeklyReportErrorContent( + state: WeeklyReportUiState.Error, + onWeekSelect: (java.time.LocalDate) -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + WeeklyReportReviewCard( + selectedMonday = state.failedMonday, + weekOptions = state.weekOptions, + onWeekSelect = onWeekSelect, + dateRange = "", + counts = emptyList(), + ) + Text( + text = state.message.asString(), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + ) + TextButton(onClick = onRetry) { + Text( + text = stringResource(R.string.mindrecord_weekly_report_retry), + style = AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + } + } +} + /** * 감정 카드 본문 문구. * @@ -240,6 +335,7 @@ private fun WeeklyReportScreenPreview() { ), onWeekSelect = {}, onEmotionAnalysisRetry = {}, + onRetry = {}, ) } } 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 0bebd964e6..aac9927cca 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 @@ -39,9 +39,30 @@ sealed interface WeeklyReportUiState { */ val summaryText: String, val dailyQuestions: List, + /** + * 주차 조회에 실패했지만 **직전에 보던 리포트가 남아 있는** 상태 (#723). + * + * 실패해도 화면 전체를 오류로 바꾸지 않는다. 리포트와 주차 선택 UI 를 유지한 채 + * 배너로만 알려야 사용자가 재시도하거나 다른 주차로 옮길 수 있다. + */ + val loadFailure: LoadFailure? = null, ) : WeeklyReportUiState + /** 화면을 유지한 채 알리는 조회 실패. [failedWeekLabel] 은 사용자가 고른 주의 월요일. */ + data class LoadFailure( + val message: UiText, + val failedWeekLabel: LocalDate, + ) + + /** + * 보여 줄 리포트가 하나도 없는 실패 (첫 조회부터 실패). + * + * 이때도 [weekOptions] 와 [failedMonday] 를 함께 넘긴다 — 재시도와 주차 재선택 수단을 + * 화면에 남겨야 복구가 가능하다. 종전에는 오류 문구 하나만 렌더해 둘 다 사라졌다 (#723). + */ data class Error( val message: UiText, + val weekOptions: List, + val failedMonday: LocalDate, ) : 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 41854664c0..13bce86c0c 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 @@ -65,6 +65,22 @@ class WeeklyReportViewModel fun selectWeek(monday: LocalDate) = load(monday) + /** + * 실패한 주차를 그대로 다시 조회한다 (#723). + * + * 실패 상태에서도 화면에 남은 재시도 수단이다. 실패 이력이 없으면 보고 있던 주를 + * 다시 부른다. + */ + fun retry() { + val target = + when (val phase = internalState.value.loadPhase) { + is LoadPhase.Failed -> phase.monday + is LoadPhase.Loaded -> phase.monday + LoadPhase.Loading -> return + } + load(target) + } + /** * 감정 분석 실패 상태에서 사용자가 누르는 재시도. * @@ -85,9 +101,14 @@ class WeeklyReportViewModel fun refreshOnReturn() { // 진입 직후의 ON_RESUME 은 init 로드와 겹친다 — 진행 중이면 건너뛴다. if (loadJob?.isActive == true) return + // 실패 상태면 **실패한 주**를 다시 시도한다. 이번 주로 되돌아가면 사용자가 + // 보려던 주차가 유실돼, 나갔다 들어와도 복구되지 않는다 (#723). val current = - (internalState.value.loadPhase as? LoadPhase.Loaded)?.monday - ?: weekOptions.first().monday + when (val phase = internalState.value.loadPhase) { + is LoadPhase.Loaded -> phase.monday + is LoadPhase.Failed -> phase.monday + LoadPhase.Loading -> weekOptions.first().monday + } load(current, showsLoading = false, keepsStateOnFailure = true) } @@ -99,6 +120,8 @@ class WeeklyReportViewModel loadJob?.cancel() loadJob = viewModelScope.launch { + // 실패했을 때 되돌아갈 화면을 **로딩으로 덮기 전에** 잡아 둔다 (#723). + val previousLoaded = internalState.value.loadPhase.lastLoadedOrNull() if (showsLoading) { internalState.update { it.copy(loadPhase = LoadPhase.Loading) } } @@ -132,10 +155,13 @@ class WeeklyReportViewModel current.copy( loadPhase = LoadPhase.Failed( - UiText.DynamicOrResource( - value = e.message, - fallbackResId = R.string.mindrecord_error_weekly_report_failed, - ), + message = + UiText.DynamicOrResource( + value = e.message, + fallbackResId = R.string.mindrecord_error_weekly_report_failed, + ), + monday = monday, + previous = previousLoaded, ), ) } @@ -241,16 +267,56 @@ class WeeklyReportViewModel val userName: String, ) : LoadPhase + /** + * 조회 실패. + * + * [monday] 는 **사용자가 고른 주**다. 이 값이 없으면 재진입 자동 갱신이 이번 주로 + * 되돌아가, 실패한 주를 다시 시도할 방법이 사라진다 (#723). + * + * [previous] 는 직전에 성공했던 화면이다. 남겨 두면 실패해도 리포트와 주차 선택 + * UI 를 유지할 수 있다 — 종전에는 화면 전체가 오류 문구 하나로 바뀌어, 재시도도 + * 다른 주차로 이동도 불가능했다. + */ data class Failed( val message: UiText, + val monday: LocalDate, + val previous: Loaded?, ) : LoadPhase } + /** 실패 상태에 보존해 둔 직전 성공 화면(없으면 null). */ + private fun LoadPhase.lastLoadedOrNull(): LoadPhase.Loaded? = + when (this) { + is LoadPhase.Loaded -> this + is LoadPhase.Failed -> previous + LoadPhase.Loading -> null + } + private fun InternalState.toUiState(): WeeklyReportUiState = when (val phase = loadPhase) { - LoadPhase.Loading -> WeeklyReportUiState.Loading - is LoadPhase.Failed -> WeeklyReportUiState.Error(phase.message) - is LoadPhase.Loaded -> phase.toSuccessUiState() + LoadPhase.Loading -> { + WeeklyReportUiState.Loading + } + + is LoadPhase.Loaded -> { + phase.toSuccessUiState() + } + + is LoadPhase.Failed -> { + // 직전에 보던 리포트가 있으면 그 화면을 유지하고 실패는 배너로 알린다. + // 화면 전체를 오류로 바꾸면 주차 선택 UI 까지 사라져 복구 수단이 없어진다. + phase.previous?.toSuccessUiState()?.copy( + loadFailure = + WeeklyReportUiState.LoadFailure( + message = phase.message, + failedWeekLabel = phase.monday, + ), + ) ?: WeeklyReportUiState.Error( + message = phase.message, + weekOptions = weekOptions, + failedMonday = phase.monday, + ) + } } private fun LoadPhase.Loaded.toSuccessUiState(): WeeklyReportUiState.Success { diff --git a/feature/mindrecord/presentation/src/main/res/values/strings.xml b/feature/mindrecord/presentation/src/main/res/values/strings.xml index 2d0e1c4154..1a6a3663b1 100644 --- a/feature/mindrecord/presentation/src/main/res/values/strings.xml +++ b/feature/mindrecord/presentation/src/main/res/values/strings.xml @@ -173,4 +173,7 @@ 답변을 입력해 주세요. 오늘의 질문을 불러오지 못해 저장할 수 없습니다. 잠시 후 다시 시도해 주세요. 주간 리포트를 불러오지 못했습니다. + + %1$d월 %2$d주차 리포트를 불러오지 못했어요. + 다시 시도 diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt new file mode 100644 index 0000000000..8a1ec8787a --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt @@ -0,0 +1,160 @@ +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.WeeklyReport +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.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.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.lang.reflect.Proxy +import java.time.DayOfWeek +import java.time.LocalDate + +/** + * 주차 조회 실패 뒤 복구 경로 가드 (#723). + * + * 종전에는 실패하면 화면 전체가 오류 문구 하나로 바뀌어 **리포트도 주차 선택 UI 도 + * 사라졌다.** 재진입 자동 갱신도 실패한 주차를 잊고 이번 주를 다시 불러, 나갔다 들어와도 + * 사용자가 보려던 주차로 돌아오지 못했다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WeeklyReportRecoveryTest { + private val dispatcher = StandardTestDispatcher() + private val thisMonday: LocalDate = LocalDate.now().with(DayOfWeek.MONDAY) + private val lastMonday: LocalDate = thisMonday.minusWeeks(1) + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `주차 변경이 실패해도 직전 리포트와 주차 선택이 남는다`() = + runTest(dispatcher) { + val repository = ScriptedRepository(mapOf(thisMonday to Result.success(report()))) + val viewModel = start(repository) + + viewModel.selectWeek(lastMonday) // 이 주차는 스크립트에 없어 실패한다 + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue("오류 화면으로 갈아치우지 않는다", state is WeeklyReportUiState.Success) + state as WeeklyReportUiState.Success + assertNotNull("실패를 배너로 알린다", state.loadFailure) + assertEquals("못 불러온 주차를 밝힌다", lastMonday, state.loadFailure?.failedWeekLabel) + assertTrue("주차 선택 수단이 남는다", state.weekOptions.isNotEmpty()) + } + + @Test + fun `첫 조회부터 실패해도 주차 선택과 실패한 주차가 남는다`() = + runTest(dispatcher) { + val repository = ScriptedRepository(emptyMap()) + val viewModel = start(repository) + + val state = viewModel.uiState.value + assertTrue(state is WeeklyReportUiState.Error) + state as WeeklyReportUiState.Error + assertTrue("이 화면에서 빠져나갈 수단이 있어야 한다", state.weekOptions.isNotEmpty()) + assertEquals(thisMonday, state.failedMonday) + } + + @Test + fun `재진입 자동 갱신은 이번 주가 아니라 실패한 주차를 다시 부른다`() = + runTest(dispatcher) { + val repository = ScriptedRepository(mapOf(thisMonday to Result.success(report()))) + val viewModel = start(repository) + viewModel.selectWeek(lastMonday) + advanceUntilIdle() + + // 나갔다 돌아옴 — 그 사이 서버가 복구됐다고 본다. + repository.script = mapOf(lastMonday to Result.success(report(diaryAmount = 7))) + viewModel.refreshOnReturn() + advanceUntilIdle() + + val state = viewModel.uiState.value as WeeklyReportUiState.Success + assertEquals("사용자가 보려던 주차로 복구된다", lastMonday, state.selectedMonday) + assertNull("복구되면 배너는 사라진다", state.loadFailure) + } + + @Test + fun `재시도는 실패한 주차를 그대로 다시 부른다`() = + runTest(dispatcher) { + val repository = ScriptedRepository(mapOf(thisMonday to Result.success(report()))) + val viewModel = start(repository) + viewModel.selectWeek(lastMonday) + advanceUntilIdle() + + repository.script = mapOf(lastMonday to Result.success(report())) + viewModel.retry() + advanceUntilIdle() + + val state = viewModel.uiState.value as WeeklyReportUiState.Success + assertEquals(lastMonday, state.selectedMonday) + assertEquals(listOf(lastMonday, lastMonday), repository.requested.takeLast(2)) + } + + // ── 테스트 도구 ─────────────────────────────────────────────────────────── + + private class ScriptedRepository( + var script: Map>, + ) : WeeklyReportRepository { + val requested = mutableListOf() + + override suspend fun getWeeklyReport(date: String): Result { + val monday = LocalDate.parse(date) + requested += monday + return script[monday] ?: Result.failure(IllegalStateException("timeout")) + } + } + + private fun report(diaryAmount: Int = 0) = + WeeklyReport( + dailyQuestionAmount = 0, + diaryAmount = diaryAmount, + summaryText = "", + week = emptyList(), + dailyQuestions = emptyList(), + emotions = emptyList(), + emotionAnalysis = EmotionAnalysis(total = 0, succeeded = 0, pending = 0, failed = 0), + ) + + private fun TestScope.start(repository: WeeklyReportRepository): WeeklyReportViewModel { + val viewModel = WeeklyReportViewModel(repository, userRepository()) + backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } + advanceUntilIdle() + return viewModel + } + + 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 52c42f50ab464f4db3f137317adbf0d918f8deca Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 15:44:46 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(mindrecord):=20=EC=A3=BC=EA=B0=84?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=83=81=EB=8B=A8=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=EB=8F=84=20=ED=99=94=EB=A9=B4=EC=9D=B4=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=8A=94=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EC=84=BC?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 원본 dailyQuestionAmount 를 그대로 쓰면 범위 밖 항목이나 날짜를 해석하지 못해 목록에서 빠진 항목이 수치에만 남는다 — #547 이 지우려던 불일치가 방향만 뒤집힌 채 남는다. 목록·기록일수와 같은 dailyQuestionsInWeek 를 세고, 테스트에 카드 수치 단언을 얹어 고정한다. 옮겨간 파싱의 잔여 주석도 지운다. 리뷰 지적. Co-Authored-By: Claude Opus 5 (1M context) --- .../presentation/viewmodel/WeeklyReportViewModel.kt | 8 +++++--- .../presentation/viewmodel/WeeklyReportWeekRangeTest.kt | 8 ++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) 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 41854664c0..291ffe65c1 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 @@ -272,7 +272,11 @@ class WeeklyReportViewModel ), counts = listOf( - report.dailyQuestionAmount to MindRecordCategoryUi.DailyQuestion, + // 서버 원본(`dailyQuestionAmount`) 이 아니라 **화면이 실제로 그리는 목록**을 + // 센다. 원본을 쓰면 범위 밖 항목이나 날짜를 해석하지 못해 빠진 항목이 + // 목록에는 없는데 수치에만 남는다 — 방향만 뒤집힌 같은 불일치다 (#547). + dailyQuestionsInWeek.size to MindRecordCategoryUi.DailyQuestion, + // 일기는 이 화면에 대응 목록이 없어 서버 수치를 그대로 쓴다. report.diaryAmount to MindRecordCategoryUi.Diary, ), emotionAnalysisStatus = report.emotionAnalysis.status, @@ -317,7 +321,5 @@ class WeeklyReportViewModel date = date, content = content, ) - - // 서버는 "yyyy.MM.dd 요일" 또는 ISO 포맷으로 내려옴 — 둘 다 허용. } } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt index 7a05fb111d..f07c742075 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt @@ -6,6 +6,7 @@ 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.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.presentation.model.MindRecordCategoryUi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -94,6 +95,13 @@ class WeeklyReportWeekRangeTest { assertEquals("같은 날 2건은 목록에 2건", 2, state.dailyQuestions.size) assertEquals("기록일수는 날짜 단위라 1일", 1, state.recordedDays) + // 최상단 카드도 같은 목록을 센다. 서버 원본(dailyQuestionAmount = 3)을 그대로 + // 쓰면 목록 2건 · 카드 3 으로 갈려, 같은 불일치가 방향만 뒤집힌 채 남는다. + assertEquals( + "카드 수치도 필터를 탄다", + 2, + state.counts.first { it.second == MindRecordCategoryUi.DailyQuestion }.first, + ) } // ── 테스트 도구 ───────────────────────────────────────────────────────────