diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt index c4431df645..2e618b9ce5 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt @@ -35,6 +35,7 @@ import com.afternote.afternote_fe.navigation.rememberHomeTabActions import com.afternote.afternote_fe.navigation.rememberReceiverNavActions import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.afternote_fe.test.FakeErrorReporter +import com.afternote.afternote_fe.test.emptyWeeklyReport import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.repository.UserProfileRepository @@ -49,7 +50,9 @@ import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfte import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel import com.afternote.feature.home.presentation.HomeTabActions import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.testing.FakeMindRecordReceiverRepository +import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepository import com.afternote.feature.mindrecord.presentation.model.MindRecordCategory import com.afternote.feature.receiver.domain.error.ReceiverFailure import com.afternote.feature.receiver.domain.error.ReceiverRejectionReason @@ -115,6 +118,9 @@ class AppAndReceiverCompletionAndroidTest { @Inject lateinit var authRepository: AuthRepository + @Inject + lateinit var weeklyReportRepository: WeeklyReportRepository + @Inject lateinit var errorReporter: ErrorReporter @@ -123,6 +129,7 @@ class AppAndReceiverCompletionAndroidTest { private val fakeAuth get() = authRepository as FakeAuthRepository private val fakeErrorReporter get() = errorReporter as FakeErrorReporter + private val fakeWeeklyReport get() = weeklyReportRepository as FakeWeeklyReportRepository @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) @@ -141,6 +148,12 @@ class AppAndReceiverCompletionAndroidTest { @Before fun inject() { hiltRule.inject() + // 홈이 진입 시 주간 기록 수를 부른다 (#562). 정본 fake 는 큐가 비면 터뜨리므로, + // 주간 수에 관심이 없는 이 테스트도 기대하는 응답을 명시적으로 넣는다 — 조용히 접으면 + // 요청 횟수가 어긋난 것을 놓친다. + (weeklyReportRepository as FakeWeeklyReportRepository).results.addLast( + Result.success(emptyWeeklyReport()), + ) } @Test @@ -207,6 +220,8 @@ class AppAndReceiverCompletionAndroidTest { fakeAuth.attemptedEmailLogins, ) assertEquals(1, fakeAuth.saveSessionCalls) + assertEquals(0, fakeAuth.rotateTokenCalls) + assertEquals(1, fakeWeeklyReport.requestedDates.size) assertTrue(fakeErrorReporter.failures.isEmpty()) } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt index 0bb31e1349..636b478904 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppOnboardingCanaryTest.kt @@ -15,12 +15,15 @@ import androidx.compose.ui.test.waitUntilAtLeastOneExists import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.afternote_fe.test.emptyWeeklyReport import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.repository.UserRepository import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.domain.testing.FakeAuthRepository import com.afternote.core.domain.testing.FakeUserRepository import com.afternote.core.model.Session +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepository import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Assert.assertEquals @@ -41,11 +44,15 @@ class AppOnboardingCanaryTest { @Inject lateinit var authRepository: AuthRepository + @Inject + lateinit var weeklyReportRepository: WeeklyReportRepository + @Inject lateinit var userRepository: UserRepository private val fakeAuth get() = authRepository as FakeAuthRepository private val fakeUser get() = userRepository as FakeUserRepository + private val fakeWeeklyReport get() = weeklyReportRepository as FakeWeeklyReportRepository @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) @@ -64,6 +71,12 @@ class AppOnboardingCanaryTest { @Before fun inject() { hiltRule.inject() + // 홈이 진입 시 주간 기록 수를 부른다 (#562). 정본 fake 는 큐가 비면 터뜨리므로, + // 주간 수에 관심이 없는 이 테스트도 기대하는 응답을 명시적으로 넣는다 — 조용히 접으면 + // 요청 횟수가 어긋난 것을 놓친다. + (weeklyReportRepository as FakeWeeklyReportRepository).results.addLast( + Result.success(emptyWeeklyReport()), + ) } @Test @@ -123,6 +136,9 @@ class AppOnboardingCanaryTest { ) assertEquals(2, fakeAuth.attemptedEmailLogins.size) assertEquals(1, fakeAuth.saveSessionCalls) + assertEquals(0, fakeAuth.rotateTokenCalls) + assertEquals(1, fakeWeeklyReport.requestedDates.size) + // `logActivityCalls` 단언은 걷는다 — #1413 이 활동 ping 자체를 제거해 develop 에 그 API 가 없다. } @Test 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..0721afd5f7 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt @@ -0,0 +1,182 @@ +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.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 +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 + +/** + * 실제 홈 경로가 주간 기록 수를 그리드까지 **전달하는지** (#562). + * + * 원래 결함은 그리드가 값을 못 그린 것이 아니라 **호출부가 값을 넘기지 않은 것**이었다. + * 컴포넌트에 숫자를 직접 넣는 테스트는 그 자리를 지나지 않으므로, ViewModel 이 만든 상태를 + * 화면에 물려 숫자가 실제로 보이는 데까지를 여기서 고정한다. + */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class HomeWeeklyCountAndroidTest { + @get:Rule(order = 0) + 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) + + composeRule.setContent { + AfternoteTheme { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + HomeTabScreen(uiState = uiState, todayDateText = "2026.04.10") + } + } + + // 3 + 4 = 7. 이 숫자가 보이려면 UseCase → UiState → HomeTabScreen → WeeklySummaryGrid + // 배선이 전부 살아 있어야 한다. + awaitSuccess(viewModel) + composeRule.scrollToWeeklyCard() + 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") + } + } + + awaitSuccess(viewModel) + composeRule.scrollToWeeklyCard() + composeRule.onNodeWithText(UNKNOWN_COUNT).assertIsDisplayed() + } + + /** + * 상태가 Success 가 될 때까지 기다린다. 화면 단언보다 먼저 두는 이유는, 조회가 끝나기 전에 + * 스크롤하면 주간 카드가 아직 목록에 없어 스크롤 자체가 실패하기 때문이다. + */ + private fun awaitSuccess(viewModel: HomeTabViewModel) { + composeRule.waitUntil(timeoutMillis = TIMEOUT) { + viewModel.uiState.value is HomeTabUiState.Success + } + } + + /** + * 주간 카드까지 스크롤한다. + * + * `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( + 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 WEEKLY_CARD_LABEL = "THIS WEEK" + 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, + ), + ) + } +} diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt index ec28e2094a..23f6791e61 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt @@ -9,6 +9,8 @@ import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount import com.afternote.core.model.user.UserPushSetting import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis +import com.afternote.feature.mindrecord.domain.model.WeeklyReport fun afternoteEditorSavedStateHandle( initialType: AfternoteType, @@ -88,3 +90,22 @@ private val DEFAULT_TEST_PUSH_SETTING = UserPushSetting(true, true, true) private fun defaultConnectedAccounts(email: String): UserConnectedAccount = UserConnectedAccount(true, false, false, false, false, email, null, null, null, null) + +/** + * 홈이 진입 시 부르는 주간 리포트의 «빈 응답» (#562). + * + * 정본 [com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepository] 는 큐가 비면 + * 터뜨린다 — 조용히 빈 리포트를 돌려주면 요청 횟수가 어긋난 것을 놓치기 때문이다. 그래서 홈에 + * 닿는 계측 테스트는 주간 수에 관심이 없더라도 **자기가 기대하는 응답을 명시적으로 큐에 넣는다.** + */ +fun emptyWeeklyReport(): WeeklyReport = + WeeklyReport( + dailyQuestionAmount = 0, + diaryAmount = 0, + summaryText = "", + week = emptyList(), + dailyQuestions = emptyList(), + emotions = emptyList(), + // 분석 상태는 이 테스트들의 관심사가 아니다 — 완료로 고정해 폴링이 끼어들지 않게 한다. + emotionAnalysis = EmotionAnalysis(total = 0, succeeded = 0, pending = 0, failed = 0), + ) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt index 7b12dfa679..2bd0837fc1 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt @@ -60,13 +60,28 @@ object TestMindRecordRepositoryModule { fun provideMindRecordReceiverRepository(impl: MindRecordReceiverRepositoryImpl): MindRecordReceiverRepository = impl /** - * 실제 구현은 네트워크를 탄다 — 주간리포트 탭이 합성되기만 해도 계측이 실서버에 붙는다. - * 요청한 주차를 기록하는 fake 로 바꿔, 「열지 않은 탭은 부르지 않는다」(#736) 를 **횟수로** - * 단언할 수 있게 한다. + * 주간 리포트도 fake 로 격리한다. + * + * 실제 구현은 네트워크를 탄다 — 홈이 진입 시 주간 기록 수를 부르고(#562) 주간리포트 탭도 + * 합성되기만 하면 이 경로를 지나므로, 실제 구현으로 두면 계측이 실서버에 붙는다. CI stub + * 응답이 TokenAuthenticator 를 타고 FakeAuthRepository 로 흘러들어 인증 테스트가 깨졌다. + * + * 정본 fixture 를 쓴다 — 시나리오마다 클래스를 새로 만들면 계약이 바뀔 때 고칠 곳이 갈라진다 + * (#936 · #1022 · #1030 의 androidTest 컴파일 파손 원인). 요청한 주차가 `requestedDates` 에 + * 남아 「열지 않은 탭은 부르지 않는다」(#736) 를 횟수로 단언할 수 있다. + * + * **`fallback` 을 준다 — 큐가 비어도 터뜨리지 않는다.** 정본 fixture 의 기본은 「큐가 비면 실패」 + * 이고 화면 단위 테스트는 그게 맞다. 그런데 앱 전체를 띄우는 계측은 **홈을 지나가기만 해도** 이 + * 저장소를 부르고, 그 횟수는 화면 전환·`ON_RESUME` 에 따라 달라져 `@Before` 에서 미리 셀 수 없다. + * 큐만 두면 두 번째 호출에서 터져 **홈이 못 뜨고, 실패는 「인사말이 안 보인다」 같은 엉뚱한 + * 자리에서 나타난다** — 실제로 `mode: full` 계측에서 그렇게 나왔다 (#562 · #1288). + * + * **세는 단언은 그대로다.** `fallback` 을 줘도 `requestedDates` 는 계속 쌓이므로 「열지 않은 탭은 + * 부르지 않는다」(#736) 를 횟수로 단언할 수 있고, 특정 응답이 필요한 테스트는 종전대로 큐에 넣는다. */ @Provides @Singleton - fun provideWeeklyReportRepository(): WeeklyReportRepository = FakeWeeklyReportRepository() + fun provideWeeklyReportRepository(): WeeklyReportRepository = FakeWeeklyReportRepository(fallback = Result.success(emptyWeeklyReport())) } @Module 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" /> + +