From e0a01991311b34b2115d939d360e0ef5d61dabfe Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 21:32:08 +0900 Subject: [PATCH 01/10] =?UTF-8?q?fix(home):=20=EC=A3=BC=EA=B0=84=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=20=EA=B7=B8=EB=A6=AC=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EA=B8=B0=EB=A1=9D=20=EC=88=98=EB=A5=BC=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파라미터 기본값이 자리표시자 7 이었고 호출부가 값을 넘기지 않아, 이번 주 기록이 0건인 사용자에게도 7 이 그려졌다. 홈 요약이 GetWeeklyRecordCountUseCase 로 이번 주 수를 함께 조립하고 그리드까지 싣는다. 값이 없을 때는 0 이 아니라 «–» 다. 조회 실패를 0 으로 접으면 «기록 없음» 을 확정하게 된다 — 수신자 홈의 실패 표기(#952)와 같은 규칙이다. 0 은 확정값이라 그대로 숫자로 그린다. 재시도가 #736 의 «데이터가 안 바뀌었으면 건너뛴다» 가드를 타지 않게 한다. 사용자가 누른 동작이고 서버 분석이 끝났는지는 다시 물어봐야만 알 수 있어, 그 가드를 태우면 실패 화면에서 버튼이 영영 아무 일도 하지 않는다. Co-Authored-By: Claude Opus 5 (1M context) --- .../home/presentation/HomeTabViewModel.kt | 1 + .../viewmodel/WeeklyReportViewModel.kt | 29 +++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabViewModel.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabViewModel.kt index bf2abcc3ef..a5bf23084c 100644 --- a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabViewModel.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/HomeTabViewModel.kt @@ -132,6 +132,7 @@ private fun HomeSummary.toHomeTabSuccess(): HomeTabUiState.Success = userName = userName, isRecipientDesignated = isRecipientDesignated, todayQuestionContent = todayQuestionContent, + weeklyRecordCount = weeklyRecordCount, categoryCounts = MindRecordCategory.entries.associateWith { category -> when (category) { 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 6afac33af0..3973a11cc4 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 @@ -97,7 +97,12 @@ class WeeklyReportViewModel // 다만 이쪽은 **사용자가 누른** 갱신이라 진행 표시를 낸다 — BE#117 전까지는 // 재조회가 같은 FAILED 를 돌려주는 것이 기본 경로이고, 그러면 데이터가 같아 // StateFlow 가 방출조차 하지 않아 눌러도 픽셀이 안 바뀐다. - refreshOnReturn(showsLoading = true) + // 재조회 대상 주 선택은 refreshOnReturn 과 같은 규칙을 쓴다. + // + // 다만 **#736 의 «데이터가 안 바뀌었으면 건너뛴다» 가드는 타지 않는다.** 재시도는 + // 사용자가 누른 동작이고, 서버 분석이 그 사이에 끝났는지는 다시 물어봐야만 안다 — + // 그 가드를 태우면 실패 화면에서 버튼이 영영 아무 일도 하지 않는다. + load(targetMonday(), showsLoading = true, keepsStateOnFailure = true) } /** @@ -115,16 +120,22 @@ class WeeklyReportViewModel if (phase is LoadPhase.Loaded && loadedVersion == changeTracker.version) return // 실패 상태면 **실패한 주**를 다시 시도한다. 이번 주로 되돌아가면 사용자가 // 보려던 주차가 유실돼, 나갔다 들어와도 복구되지 않는다 (#723). - val current = - // 실패한 주도 기억해 둔다 — 재시도가 최신 주로 되돌아가면 보던 주를 잃는다 (#723). - when (val phase = internalState.value.loadPhase) { - is LoadPhase.Loaded -> phase.monday - is LoadPhase.Failed -> phase.monday - LoadPhase.Loading -> weekOptions.first().monday - } - load(current, showsLoading = showsLoading, keepsStateOnFailure = true) + load(targetMonday(), showsLoading = showsLoading, keepsStateOnFailure = true) } + /** + * 다시 조회할 주 — 보고 있던 주를 그대로 쓴다. + * + * 실패한 주도 기억해 둔다. 최신 주로 되돌아가면 사용자가 보려던 주차가 유실돼 + * 나갔다 들어와도 복구되지 않는다 (#723). + */ + private fun targetMonday(): LocalDate = + when (val phase = internalState.value.loadPhase) { + is LoadPhase.Loaded -> phase.monday + is LoadPhase.Failed -> phase.monday + LoadPhase.Loading -> weekOptions.first().monday + } + private fun load( monday: LocalDate, showsLoading: Boolean = true, From 818977cfce431a49756e2700a54332a2871bd5e9 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 21:33:18 +0900 Subject: [PATCH 02/10] =?UTF-8?q?style(mindrecord):=20=EB=A8=B8=EC=A7=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B2=B9=EC=B9=9C=20=EA=B3=A0=EC=95=84=20?= =?UTF-8?q?KDoc=20=EC=9D=84=20=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #736 과 #724 의 문서가 refreshOnReturn 앞에 나란히 남아 ktlint 가 «KDoc 이 KDoc 앞에 올 수 없다» 로 잡았다. 뒤쪽(현행 동작을 설명하는) 하나만 남긴다. Co-Authored-By: Claude Opus 5 (1M context) --- .../viewmodel/DailyQuestionListViewModel.kt | 7 ------- .../presentation/viewmodel/DiaryListViewModel.kt | 10 ---------- 2 files changed, 17 deletions(-) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt index 811085c7d9..bdccc7e008 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionListViewModel.kt @@ -44,13 +44,6 @@ class DailyQuestionListViewModel load() } - /** - * 탭 전환·작성 화면 복귀 등 사용자가 요청하지 않은 자동 갱신. - * - * 화면이 살아 있는 채로 발화하므로 로딩을 방출하지 않고(콘텐츠가 통째 교체되면 - * 캘린더 월과 스크롤 위치가 함께 폐기된다) 실패해도 보고 있던 화면을 유지한다. - */ - /** * 탭 전환·`ON_RESUME` 등 사용자가 요청하지 않은 자동 갱신. * diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt index f93509ca65..bf63a33242 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryListViewModel.kt @@ -52,16 +52,6 @@ class DiaryListViewModel load(yearMonth) } - /** - * 탭 전환·작성 화면 복귀 등 사용자가 요청하지 않은 자동 갱신. - * - * [selectYearMonth] 와 세 가지가 다르다. - * - 로딩을 방출하지 않는다. 화면이 살아 있는 채로 발화하므로 콘텐츠가 통째 교체되면 - * 캘린더 월과 스크롤 위치가 함께 폐기된다. - * - 실패해도 보고 있던 화면을 유지한다. - * - 보고 있던 월을 그대로 다시 조회한다. 6월을 보는 중에 7월 데이터를 받으면 안 된다. - */ - /** * 탭 전환·`ON_RESUME` 등 사용자가 요청하지 않은 자동 갱신. * From 62c8cde905a8585dd81893b653c1e8092c072c1a Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Wed, 26 Aug 2026 17:12:19 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix(home):=20=EB=8A=90=EB=A6=B0=20?= =?UTF-8?q?=EC=A3=BC=EA=B0=84=20=EC=88=98=20=EC=A1=B0=ED=9A=8C=EA=B0=80=20?= =?UTF-8?q?=ED=99=88=20=EC=A0=84=EC=B2=B4=EB=A5=BC=20=EB=B6=99=EC=9E=A1?= =?UTF-8?q?=EC=A7=80=20=EB=AA=BB=ED=95=98=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주간 수만 `/mind-record` 에서 오는데 그 엔드포인트가 병적으로 느리다(#1122). 홈 요약이 네 조회를 모두 기다리는 구조라, 이름·오늘의 질문·NEXT STEP 이 250ms 에 도착해 있어도 주간 수가 끝날 때까지 홈 전체가 shimmer 로 남았다. 실기동 실측 — 60,179ms 뒤 SocketTimeout. 예산(3초)을 넘긴 주간 수는 «못 불러옴»(null) 과 같이 다룬다. 그리드가 이미 그 상태를 그리므로 새 상태가 늘지 않고, 홈은 나머지 응답 속도로 뜬다. 예산은 **async 안에서** 건다. 밖에서 걸면 `await()` 만 끊기고 그 async 는 여전히 coroutineScope 의 자식이라 scope 가 끝까지 기다린다 — 처음에 그렇게 짰다가 가드 테스트가 「홈이 주간 수를 끝까지 기다렸다: 60000ms」로 잡아냈다. HomeWeeklyCountBudgetTest 가 양방향을 고정한다: 느리면 기다리지 않고, 예산 안에 오면 그 값을 쓴다(뒤가 없으면 «항상 미상» 이 돼 #562 가 되돌아간다). Co-Authored-By: Claude Opus 5 (1M context) --- .../usecase/GetHomeSummaryUseCase.kt | 23 ++- .../presentation/HomeWeeklyCountBudgetTest.kt | 136 ++++++++++++++++++ 2 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeWeeklyCountBudgetTest.kt diff --git a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt index 1ee86d7fe6..c0bfaf2504 100644 --- a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt @@ -8,6 +8,7 @@ import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.usecase.GetWeeklyRecordCountUseCase import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withTimeoutOrNull import java.time.YearMonth import javax.inject.Inject @@ -41,14 +42,26 @@ class GetHomeSummaryUseCase // 결과적으로 카운트는 "이번 달 작성된 일기 수" 의미가 됨. val diaryDeferred = async { diaryRepository.getList(yearMonth = thisMonth) } val todayQuestionDeferred = async { dailyQuestionRepository.getToday() } - val weeklyCountDeferred = async { getWeeklyRecordCount() } + // 예산을 **async 안에서** 건다. 밖에서 걸면 `await()` 만 끊기고 이 async 는 + // 여전히 coroutineScope 의 자식이라, scope 가 그 자식을 끝까지 기다린다 — + // 타임아웃을 걸고도 60초를 그대로 서 있게 된다(가드 테스트가 잡았다). + val weeklyCountDeferred = + async { withTimeoutOrNull(WEEKLY_COUNT_BUDGET_MILLIS) { getWeeklyRecordCount() } } val profile = profileDeferred.await() val receivers = receiversDeferred.await() val diaryCount = diaryDeferred.await().getOrNull()?.monthDiaryCount ?: 0 val todayQuestionContent = todayQuestionDeferred.await().getOrNull()?.content // 실패를 0 으로 접지 않는다 — «기록이 없음» 과 «못 불러옴» 은 다르다 (#562). - val weeklyRecordCount = weeklyCountDeferred.await().getOrNull() + // + // **주간 수는 홈 전체를 붙잡지 못하게 한다.** 이 값만 `/mind-record` 에서 + // 오는데 그 엔드포인트가 병적으로 느려(#1122), 읽기 타임아웃 60초를 그대로 + // 기다리면 이름·오늘의 질문·NEXT STEP 이 250ms 에 도착해 있어도 홈 전체가 + // 그 동안 shimmer 로 남는다. 실기동 실측: 60,179ms 뒤 SocketTimeout. + // + // 예산을 넘기면 «못 불러옴»(null) 과 같이 다룬다 — 그리드가 이미 그 상태를 + // 그리므로 새 상태가 늘지 않고, 홈은 나머지 응답 속도로 뜬다. + val weeklyRecordCount = weeklyCountDeferred.await()?.getOrNull() HomeSummary( userName = profile.name, @@ -60,3 +73,9 @@ class GetHomeSummaryUseCase } } } + +/** + * 주간 기록 수를 기다려 줄 예산. 홈의 나머지 조회는 실측 250ms 안팎이라 그보다 넉넉하되, + * 사용자가 «멈췄다» 고 느끼기 전에 끝난다. + */ +private const val WEEKLY_COUNT_BUDGET_MILLIS = 3_000L diff --git a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeWeeklyCountBudgetTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeWeeklyCountBudgetTest.kt new file mode 100644 index 0000000000..d8462ab3a7 --- /dev/null +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeWeeklyCountBudgetTest.kt @@ -0,0 +1,136 @@ +package com.afternote.feature.home.presentation + +import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.model.user.Receiver +import com.afternote.core.model.user.User +import com.afternote.feature.home.presentation.usecase.GetHomeSummaryUseCase +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload +import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload +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.TodayDailyQuestion +import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.usecase.GetWeeklyRecordCountUseCase +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.reflect.Proxy + +/** + * 느린 주간 수 조회가 홈 전체를 붙잡지 못하는지 (#562 리뷰). + * + * 주간 수만 `/mind-record` 에서 오는데 그 엔드포인트가 병적으로 느리다(#1122). 이 값을 + * 끝까지 기다리면 이름·오늘의 질문·NEXT STEP 이 이미 도착해 있어도 홈 전체가 그 동안 + * shimmer 로 남는다 — 실기동에서 **60,179ms 뒤 SocketTimeout** 까지 갔다. + * + * 예산을 넘긴 주간 수는 «못 불러옴»(null) 과 같이 다룬다. 그리드가 이미 그 상태를 그리므로 + * 새 상태가 늘지 않는다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class HomeWeeklyCountBudgetTest { + @Test + fun `주간 수가 느려도 홈은 나머지 응답 속도로 뜬다`() = + runTest { + // 서버가 읽기 타임아웃(60초)까지 답하지 않는 경우다. + val useCase = useCase(weeklyDelayMillis = 60_000) + + val summary = useCase().getOrThrow() + + assertTrue("홈이 주간 수를 끝까지 기다렸다: ${currentTime}ms", currentTime < 10_000) + assertEquals("효기", summary.userName) + assertNull("예산을 넘긴 주간 수는 «못 불러옴» 으로 다룬다", summary.weeklyRecordCount) + } + + @Test + fun `예산 안에 오면 그 값을 쓴다`() = + runTest { + // 예산이 값을 버리는 쪽으로만 작동하면 «항상 미상» 이 되고 #562 가 되돌아간다. + val useCase = useCase(weeklyDelayMillis = 100) + + assertEquals(3, useCase().getOrThrow().weeklyRecordCount) + } + + private fun useCase(weeklyDelayMillis: Long): GetHomeSummaryUseCase = + GetHomeSummaryUseCase( + userRepository = StubUserRepository, + diaryRepository = StubDiaryRepository, + dailyQuestionRepository = StubDailyQuestionRepository, + getWeeklyRecordCount = GetWeeklyRecordCountUseCase(SlowWeeklyReportRepository(weeklyDelayMillis)), + ) +} + +private class SlowWeeklyReportRepository( + private val delayMillis: Long, +) : WeeklyReportRepository { + override suspend fun getWeeklyReport(date: String): Result { + delay(delayMillis) + return Result.success( + WeeklyReport( + dailyQuestionAmount = 1, + diaryAmount = 2, + summaryText = "", + week = emptyList(), + dailyQuestions = emptyList(), + emotions = emptyList(), + emotionAnalysis = null, + ), + ) + } +} + +/** UserRepository 는 표면이 넓다 — 이 시나리오가 타는 호출만 답한다. */ +private val StubUserRepository: UserRepository = + Proxy.newProxyInstance( + UserRepository::class.java.classLoader, + arrayOf(UserRepository::class.java), + ) { _, method, _ -> + when (method.name) { + "getMyProfile" -> User(name = "효기", email = "user@example.com", phone = null, profileImageUrl = null) + "getReceivers" -> emptyList() + else -> error("Unexpected call: ${method.name}") + } + } as UserRepository + +private object StubDiaryRepository : DiaryRepository { + override suspend fun getList( + yearMonth: String, + draftOnly: Boolean?, + ): Result = Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null)) + + override suspend fun create(payload: DiaryCreatePayload): Result = error("호출되면 안 됨") + + override suspend fun update( + id: Long, + payload: DiaryUpdatePayload, + ): Result = error("호출되면 안 됨") + + override suspend fun delete(id: Long): Result = error("호출되면 안 됨") +} + +private object StubDailyQuestionRepository : DailyQuestionRepository { + override suspend fun getList( + date: String?, + draftOnly: Boolean?, + ): Result> = Result.success(emptyList()) + + override suspend fun getToday(): Result = Result.failure(IllegalStateException("이 테스트의 관심사가 아니다")) + + override suspend fun create(payload: DailyQuestionCreatePayload) = error("호출되면 안 됨") + + override suspend fun update( + id: Long, + payload: DailyQuestionUpdatePayload, + ) = error("호출되면 안 됨") + + override suspend fun delete(id: Long): Result = error("호출되면 안 됨") +} From b277c9306824ae5b4038a2f45c3d3c7394670601 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 27 Aug 2026 19:37:20 +0900 Subject: [PATCH 04/10] =?UTF-8?q?test(home):=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=ED=99=88=20=EA=B2=BD=EB=A1=9C=EA=B0=80=20=EC=A3=BC=EA=B0=84=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=88=98=EB=A5=BC=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=EA=B9=8C=EC=A7=80=20=EC=A0=84=EB=8B=AC=ED=95=98?= =?UTF-8?q?=EB=8A=94=EC=A7=80=20=EA=B3=84=EC=B8=A1=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원래 결함은 그리드가 값을 못 그린 것이 아니라 **호출부가 값을 넘기지 않은 것**이었다. WeeklySummaryGridDataTest 는 컴포넌트에 숫자를 직접 넣어 그 자리를 지나지 않는다. HomeWeeklyCountAndroidTest 가 실제 ViewModel 이 만든 상태를 HomeTabScreen 에 물려 숫자가 화면에 보이는 데까지를 고정한다 — UseCase → UiState → HomeTabScreen → WeeklySummaryGrid 배선이 전부 살아 있어야 통과한다. - 주간 수 3+4=7 이 그리드에 보인다 - 조회 실패면 0 이 아니라 미상(대시)으로 남는다 (반대 방향) 에뮬레이터 실행 통과했고, 호출부를 `recordedCount = null` 로 되돌리면 앞 테스트가 실패하는 것을 확인했다 — 회귀 원인 그 자리를 정확히 문다. develop 을 물려 androidTest fixture(EditorFlowRoute.initialType) 수정을 받는다. Co-Authored-By: Claude Opus 5 (1M context) --- .../HomeWeeklyCountAndroidTest.kt | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt new file mode 100644 index 0000000000..48e0acf9b1 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt @@ -0,0 +1,139 @@ +package com.afternote.afternote_fe + +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.afternote_fe.test.appTestUserRepository +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.testing.FakeUserProfileRepository +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.home.presentation.HomeTabScreen +import com.afternote.feature.home.presentation.HomeTabViewModel +import com.afternote.feature.home.presentation.usecase.GetHomeSummaryUseCase +import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.domain.usecase.GetWeeklyRecordCountUseCase +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 실제 홈 경로가 주간 기록 수를 그리드까지 **전달하는지** (#562). + * + * 원래 결함은 그리드가 값을 못 그린 것이 아니라 **호출부가 값을 넘기지 않은 것**이었다. + * 컴포넌트에 숫자를 직접 넣는 테스트는 그 자리를 지나지 않으므로, ViewModel 이 만든 상태를 + * 화면에 물려 숫자가 실제로 보이는 데까지를 여기서 고정한다. + */ +@RunWith(AndroidJUnit4::class) +class HomeWeeklyCountAndroidTest { + @get:Rule(order = 0) + val composeRule = createComposeRule() + + @get:Rule(order = 1) + val failureArtifactRule = + FailureArtifactRule { + composeRule.onRoot().captureToImage().asAndroidBitmap() + } + + @Test + fun homeScreen_showsWeeklyRecordCountFromViewModel() { + val viewModel = homeViewModel(dailyQuestionAmount = 3, diaryAmount = 4) + + composeRule.setContent { + AfternoteTheme { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + HomeTabScreen(uiState = uiState, todayDateText = "2026.04.10") + } + } + + // 3 + 4 = 7. 이 숫자가 보이려면 UseCase → UiState → HomeTabScreen → WeeklySummaryGrid + // 배선이 전부 살아 있어야 한다. + composeRule.waitUntil(timeoutMillis = TIMEOUT) { + composeRule.onAllNodesWithText(EXPECTED_COUNT).fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText(EXPECTED_COUNT).assertIsDisplayed() + } + + @Test + fun homeScreen_showsPlaceholderWhenWeeklyCountUnavailable() { + // 조회 실패를 0 으로 접으면 «기록이 없음» 을 확정한다 — 미상은 대시로 남아야 한다 (#562). + val viewModel = homeViewModel(weeklyFailure = IllegalStateException("주간 조회 실패")) + + composeRule.setContent { + AfternoteTheme { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + HomeTabScreen(uiState = uiState, todayDateText = "2026.04.10") + } + } + + composeRule.waitUntil(timeoutMillis = TIMEOUT) { + composeRule.onAllNodesWithText(UNKNOWN_COUNT).fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText(UNKNOWN_COUNT).assertIsDisplayed() + } + + private fun homeViewModel( + dailyQuestionAmount: Int = 0, + diaryAmount: Int = 0, + weeklyFailure: Throwable? = null, + ): HomeTabViewModel = + HomeTabViewModel( + getHomeSummary = + GetHomeSummaryUseCase( + userRepository = appTestUserRepository(), + diaryRepository = FakeDiaryRepository(), + dailyQuestionRepository = FakeDailyQuestionRepository(), + getWeeklyRecordCount = + GetWeeklyRecordCountUseCase( + FakeWeeklyReportRepository(dailyQuestionAmount, diaryAmount, weeklyFailure), + ), + ), + userProfileRepository = FakeUserProfileRepository(), + errorReporter = SilentErrorReporter, + ) + + private companion object { + const val TIMEOUT = 5_000L + const val EXPECTED_COUNT = "7" + const val UNKNOWN_COUNT = "–" + } +} + +/** 이 테스트는 보고 여부를 보지 않는다 — 조회 실패 경로가 리포터를 부르므로 자리만 채운다. */ +private object SilentErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit +} + +private class FakeWeeklyReportRepository( + private val dailyQuestionAmount: Int, + private val diaryAmount: Int, + private val failure: Throwable?, +) : WeeklyReportRepository { + override suspend fun getWeeklyReport(date: String): Result { + failure?.let { return Result.failure(it) } + return Result.success( + WeeklyReport( + dailyQuestionAmount = dailyQuestionAmount, + diaryAmount = diaryAmount, + summaryText = "", + week = emptyList(), + dailyQuestions = emptyList(), + emotions = emptyList(), + emotionAnalysis = null, + ), + ) + } +} From 88ee83d3aae868a360cc06729b2ee9ff668af7dc Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 27 Aug 2026 20:20:49 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix(test):=20=EC=A3=BC=EA=B0=84=20?= =?UTF-8?q?=EC=88=98=20=EA=B3=84=EC=B8=A1=EC=9D=84=20=EC=A2=81=EC=9D=80=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=EB=8F=84=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC=ED=95=98=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exact-head GMD 에서 두 건이 5초 timeout 이었다. WeeklySummaryGrid 는 LazyColumn 뒤쪽 item 이라 Pixel 2 API 30 뷰포트에서는 composition 되지 않는데, 노드가 «생기기만» 기다려 스스로 노출시키지 못했다 — 화면이 더 큰 로컬 에뮬레이터에서만 통과한 이유다. - 상태가 Success 가 된 뒤 performScrollToNode 로 주간 카드를 노출시키고 단언한다 - 스크롤로 MEMORIES 항목도 함께 composition 되는데, 그쪽이 hiltViewModel() 을 부른다. 맨 ComponentActivity 로는 "does not implement GeneratedComponent" 로 죽고 MainActivity 는 이미 콘텐츠를 세팅해 setContent 가 거부되므로, debug 소스셋에 빈 @AndroidEntryPoint Activity 를 두고 그것으로 띄운다 (릴리스 빌드에는 들어가지 않는다) CI 와 같은 Gradle Managed Device(pixel2Api30)로 실행해 2/2 통과했고, 호출부를 recordedCount = null 로 되돌리면 실패하는 것도 같은 기기에서 확인했다. :app:assembleRelease 도 통과해 debug 전용 Activity 가 릴리스에 새지 않는 것을 확인했다. Co-Authored-By: Claude Opus 5 (1M context) --- .../HomeWeeklyCountAndroidTest.kt | 57 ++++++++++++++++--- app/src/debug/AndroidManifest.xml | 8 +++ .../afternote_fe/test/HiltTestActivity.kt | 18 ++++++ 3 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 app/src/debug/java/com/afternote/afternote_fe/test/HiltTestActivity.kt diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt index 48e0acf9b1..0721afd5f7 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt @@ -4,18 +4,24 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.captureToImage -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.hasScrollToNodeAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performScrollToNode import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.afternote_fe.test.HiltTestActivity import com.afternote.afternote_fe.test.appTestUserRepository import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.testing.FakeUserProfileRepository import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.home.presentation.HomeTabScreen +import com.afternote.feature.home.presentation.HomeTabUiState import com.afternote.feature.home.presentation.HomeTabViewModel import com.afternote.feature.home.presentation.usecase.GetHomeSummaryUseCase import com.afternote.feature.mindrecord.domain.model.WeeklyReport @@ -23,6 +29,9 @@ import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.domain.usecase.GetWeeklyRecordCountUseCase +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -34,17 +43,30 @@ import org.junit.runner.RunWith * 컴포넌트에 숫자를 직접 넣는 테스트는 그 자리를 지나지 않으므로, ViewModel 이 만든 상태를 * 화면에 물려 숫자가 실제로 보이는 데까지를 여기서 고정한다. */ +@HiltAndroidTest @RunWith(AndroidJUnit4::class) class HomeWeeklyCountAndroidTest { @get:Rule(order = 0) - val composeRule = createComposeRule() + val hiltRule = HiltAndroidRule(this) + // 홈은 MEMORIES 섹션에서 hiltViewModel() 을 부른다. 좁은 화면(Pixel 2 API 30)에서 주간 + // 카드까지 스크롤하면 그 항목도 composition 되므로 Hilt 가 붙은 Activity 여야 한다 — + // 맨 ComponentActivity 로는 "does not implement GeneratedComponent" 로 죽는다. + // MainActivity 는 이미 콘텐츠를 세팅해 setContent 가 거부되므로 빈 테스트 Activity 를 쓴다. @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @get:Rule(order = 2) val failureArtifactRule = FailureArtifactRule { composeRule.onRoot().captureToImage().asAndroidBitmap() } + @Before + fun inject() { + hiltRule.inject() + } + @Test fun homeScreen_showsWeeklyRecordCountFromViewModel() { val viewModel = homeViewModel(dailyQuestionAmount = 3, diaryAmount = 4) @@ -58,9 +80,8 @@ class HomeWeeklyCountAndroidTest { // 3 + 4 = 7. 이 숫자가 보이려면 UseCase → UiState → HomeTabScreen → WeeklySummaryGrid // 배선이 전부 살아 있어야 한다. - composeRule.waitUntil(timeoutMillis = TIMEOUT) { - composeRule.onAllNodesWithText(EXPECTED_COUNT).fetchSemanticsNodes().isNotEmpty() - } + awaitSuccess(viewModel) + composeRule.scrollToWeeklyCard() composeRule.onNodeWithText(EXPECTED_COUNT).assertIsDisplayed() } @@ -76,10 +97,31 @@ class HomeWeeklyCountAndroidTest { } } + awaitSuccess(viewModel) + composeRule.scrollToWeeklyCard() + composeRule.onNodeWithText(UNKNOWN_COUNT).assertIsDisplayed() + } + + /** + * 상태가 Success 가 될 때까지 기다린다. 화면 단언보다 먼저 두는 이유는, 조회가 끝나기 전에 + * 스크롤하면 주간 카드가 아직 목록에 없어 스크롤 자체가 실패하기 때문이다. + */ + private fun awaitSuccess(viewModel: HomeTabViewModel) { composeRule.waitUntil(timeoutMillis = TIMEOUT) { - composeRule.onAllNodesWithText(UNKNOWN_COUNT).fetchSemanticsNodes().isNotEmpty() + viewModel.uiState.value is HomeTabUiState.Success } - composeRule.onNodeWithText(UNKNOWN_COUNT).assertIsDisplayed() + } + + /** + * 주간 카드까지 스크롤한다. + * + * `WeeklySummaryGrid` 는 `LazyColumn` 의 뒤쪽 item 이라 좁은 화면(Pixel 2 API 30)에서는 + * 아직 composition 되지 않는다. 노드가 «생기기만» 기다리면 스스로 노출되지 않아 timeout + * 이다 — 내 에뮬레이터(화면이 더 큼)에서만 통과했던 이유다 (#562 리뷰). + */ + private fun ComposeContentTestRule.scrollToWeeklyCard() { + onNode(hasScrollToNodeAction()).performScrollToNode(hasText(WEEKLY_CARD_LABEL)) + waitForIdle() } private fun homeViewModel( @@ -105,6 +147,7 @@ class HomeWeeklyCountAndroidTest { private companion object { const val TIMEOUT = 5_000L const val EXPECTED_COUNT = "7" + const val WEEKLY_CARD_LABEL = "THIS WEEK" const val UNKNOWN_COUNT = "–" } } diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index 3f1032141e..02665383df 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -18,6 +18,14 @@ android:taskAffinity=".devsettings" android:excludeFromRecents="true" android:theme="@style/Theme.Afternotefe.Main" /> + +