diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt new file mode 100644 index 0000000000..711c00e8cc --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt @@ -0,0 +1,188 @@ +package com.afternote.afternote_fe + +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.compose.ui.test.performClick +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.afternote_fe.test.FakeErrorReporter +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.domain.model.author.ListItem +import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository +import com.afternote.feature.afternote.presentation.home.AfternoteHomeEntry +import com.afternote.feature.afternote.presentation.home.AfternoteHomeViewModel +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import com.afternote.feature.afternote.presentation.R as AfternoteR + +/** + * 새로고침 실패의 표시 갈림 세 가지를 실기에서 잠근다 (#1790 · 구현은 #705). + * + * | 상태 | 그려야 하는 것 | + * |---|---| + * | 실패 + 목록 0건 | 전면 오류 본문 | + * | 실패 아님 | 둘 다 아님 | + * | 실패 + 목록 있음 | 새로고침 실패 배너 (목록은 그대로) | + * + * **왜 계측인가.** 이 갈림은 `AfternoteHomeScreen` 본문의 `when` 이 `LazyPagingItems.loadState` 와 + * `itemCount` 를 함께 보고 정한다. JVM(Robolectric)에서 화면을 띄우면 `collectAsLazyPagingItems` 의 + * 첫 로드 상태가 `Loading` 인 채로 남아 단언 시점까지 이펙트가 진행되지 않는다 — 클래스가 누적된 + * 모듈 전량 실행에서 재현되고 `waitUntil` 로도 풀리지 않는다(#1443 이 실측, PR #1608 에서 두 번 무너짐). + * 실기에서는 같은 수집이 정상으로 돌아 세 갈림이 결정적으로 그려진다. + * + * 판정 함수(`shouldShowRefreshErrorBanner`)의 공개 범위는 넓히지 않는다(#1678). 화면을 그대로 띄워 + * **그려진 결과**로 확인하므로 프로덕션 seam 도 새로 만들지 않는다 — 이 파일은 #1804(MVI Screen/Content + * 2단 전환)이 다시 쓸 화면 파일을 건드리지 않는다. + * + * 상태 전이는 페이징 소스가 만든다. 실패/성공은 [failing] 이 정하고, 「당겨 새로고침」은 + * 현재 세대의 소스를 무효화해 만든다 — `LazyPagingItems.refresh()` 가 내부에서 하는 것과 같은 일이라 + * 제스처 임계값에 결과가 좌우되지 않는다. + */ +@RunWith(AndroidJUnit4::class) +class AfternoteHomeRefreshBannerAndroidTest { + @get:Rule(order = 0) + val composeRule = createComposeRule() + + @get:Rule(order = 1) + val failureArtifactRule = + FailureArtifactRule { + composeRule.onRoot().captureToImage().asAndroidBitmap() + } + + private val failing = AtomicBoolean(true) + private val currentSource = AtomicReference?>(null) + + @Test + fun homeRefreshFailure_showsBannerOverKeptList_andFullErrorOnlyWhenListIsEmpty() { + val repository = + FakeAfternoteRepository.strict().apply { + onGetPagedAfternotes = { + Pager(PagingConfig(pageSize = 20)) { + ScriptedListPagingSource(failing, authorListItems()) + .also(currentSource::set) + }.flow + } + } + val viewModel = AfternoteHomeViewModel(repository, FakeErrorReporter()) + + composeRule.setContent { + AfternoteTheme { + AfternoteHomeEntry( + navigateToDetail = {}, + navigateToAdd = {}, + onSettingClick = {}, + viewModel = viewModel, + ) + } + } + + // 실패 + 목록 0건 → 전면 오류만. + awaitText(fullErrorCopy) + composeRule.onNodeWithText(fullErrorCopy).assertIsDisplayed() + composeRule.onNodeWithText(bannerCopy).assertDoesNotExist() + + // 실패 아님 → 둘 다 아님. 목록이 실제로 그려졌음을 함께 단언해 로딩 화면의 false green 을 막는다. + failing.set(false) + composeRule.onNodeWithText(retryCopy).performClick() + awaitText(FIRST_ITEM_NAME) + composeRule.onNodeWithText(FIRST_ITEM_NAME).assertIsDisplayed() + composeRule.onNodeWithText(bannerCopy).assertDoesNotExist() + composeRule.onNodeWithText(fullErrorCopy).assertDoesNotExist() + + // 실패 + 목록 있음 → 배너. 전면 오류가 목록을 밀어내지 않는다. + failing.set(true) + refreshList() + awaitText(bannerCopy) + composeRule.onNodeWithText(bannerCopy).assertIsDisplayed() + composeRule.onNodeWithText(fullErrorCopy).assertDoesNotExist() + composeRule.onNodeWithText(FIRST_ITEM_NAME).assertIsDisplayed() + + // 배너의 «다시 시도» 가 같은 목록을 복구하고 배너를 걷는다. + failing.set(false) + composeRule.onNodeWithText(retryCopy).performClick() + awaitTextGone(bannerCopy) + composeRule.onNodeWithText(FIRST_ITEM_NAME).assertIsDisplayed() + composeRule.onNodeWithText(fullErrorCopy).assertDoesNotExist() + } + + /** 「당겨 새로고침」과 같은 경로 — 현재 세대의 소스를 무효화해 refresh 로드를 다시 태운다. */ + private fun refreshList() { + val source = requireNotNull(currentSource.get()) { "페이징 소스가 아직 만들어지지 않았다" } + source.invalidate() + } + + private fun awaitText(text: String) { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun awaitTextGone(text: String) { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isEmpty() + } + } + + /** 문구는 리소스가 정본이다 — 문구가 바뀌어도 단언이 따라간다 (#567). */ + private fun copy(resId: Int): String = + InstrumentationRegistry + .getInstrumentation() + .targetContext + .getString(resId) + + private val fullErrorCopy: String get() = copy(AfternoteR.string.afternote_home_load_error) + private val bannerCopy: String get() = copy(AfternoteR.string.afternote_home_refresh_error) + private val retryCopy: String get() = copy(AfternoteR.string.afternote_home_retry) + + private companion object { + const val TIMEOUT_MILLIS = 10_000L + + /** 목록이 살아 있는지 보는 기준 항목. 첫 카드라 스크롤 없이 그려진다. */ + const val FIRST_ITEM_NAME = "Instagram" + } +} + +/** 실패/성공을 [failing] 으로 갈아끼우는 목록 소스. 세대마다 새 인스턴스가 필요해 팩토리가 매번 만든다. */ +private class ScriptedListPagingSource( + private val failing: AtomicBoolean, + private val items: List, +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult = + if (failing.get()) { + LoadResult.Error(IllegalStateException("offline")) + } else { + LoadResult.Page(data = items, prevKey = null, nextKey = null) + } + + override fun getRefreshKey(state: PagingState): Int? = null +} + +private fun authorListItems(): List = + listOf( + ListItem( + id = 201L, + serviceName = "Instagram", + date = "2026.09.03", + type = AfternoteType.SOCIAL_NETWORK, + ), + ListItem( + id = 202L, + serviceName = "Google Drive", + date = "2026.09.03", + type = AfternoteType.GALLERY_AND_FILES, + ), + ) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeScreen.kt index 9a9db9c9fe..8af671b464 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeScreen.kt @@ -153,7 +153,8 @@ fun AfternoteHomeScreen( * 두 표시가 겹치면 같은 실패를 두 번 말하게 된다. * * 이 파일 안에서만 쓰므로 private 다. 테스트가 부르려고 넓히지 않는다(#1678) — 세 갈래는 - * [AfternoteHomeScreen] 을 그려서 확인한다. + * [AfternoteHomeScreen] 을 그려서 확인한다. 그 가드는 `app` 의 + * `AfternoteHomeRefreshBannerAndroidTest` 다. JVM 이 아니라 계측인 이유는 그 KDoc 참조 (#1790). */ private fun shouldShowRefreshErrorBanner( refreshState: LoadState, diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeRefreshErrorTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeRefreshErrorTest.kt index 5a68bc77d7..e400bef144 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeRefreshErrorTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/home/AfternoteHomeRefreshErrorTest.kt @@ -24,10 +24,14 @@ import org.robolectric.annotation.Config * **여기서 잠그는 것은 배너 자체의 계약뿐이다.** 표시 여부를 정하는 판정은 화면 파일의 private * 함수라 테스트가 부르지 않는다 — 테스트가 부르려고 프로덕션 공개 범위를 넓히지 않는다(#1678). * - * 화면을 그려서 세 갈래를 확인하는 방법은 두 번 시도했고 CI 에서 `ComposeTimeoutException` 으로 - * 무너졌다. `collectAsLazyPagingItems` 가 로드 상태를 화면까지 옮기지 못한 채 끝나서, `waitUntil` - * 로 기다려도 배너가 나타나지 않는다. 로컬 단독 실행에서만 통과하는 단언은 가드가 아니라 소음이라 - * 남기지 않았다. 목록 유무에 따른 갈림은 [AfternoteHomeScreen] 본문의 `when` 이 직접 드러낸다. + * 화면을 그려서 세 갈래를 확인하는 방법은 **이 소스셋에서** 두 번 시도했고 CI 에서 + * `ComposeTimeoutException` 으로 무너졌다. `collectAsLazyPagingItems` 가 로드 상태를 화면까지 + * 옮기지 못한 채 끝나서 `waitUntil` 로 기다려도 배너가 나타나지 않는다 — Robolectric 클래스가 + * 누적되면 단언 시점까지 컴포지션 이펙트가 진행되지 않는다(#1443 이 실측한 기전). + * + * 그래서 **세 갈래는 계측으로 올라갔다.** `app` 의 `AfternoteHomeRefreshBannerAndroidTest` 가 + * 실기에서 실 Paging 을 태워 「실패+목록 있음 → 배너」·「실패+0건 → 전면 오류」·「실패 아님 → + * 둘 다 아님」을 잠근다 (#1790). */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [35])