From bc841743281ac84f26553efccae958c3f86fc875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 30 Aug 2026 20:50:15 +0900 Subject: [PATCH 1/3] =?UTF-8?q?test(platform):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=EC=84=A0=ED=83=9D=20=EA=B2=B0=EA=B3=BC=EA=B0=80=20?= =?UTF-8?q?=EC=86=8C=EB=B9=84=20=ED=99=94=EB=A9=B4=EC=97=90=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EB=8F=84=EC=B0=A9=ED=95=98=EB=8A=94?= =?UTF-8?q?=EC=A7=80=20=EA=B3=84=EC=B8=A1=ED=95=9C=EB=8B=A4=20(#841)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공용 수신자 선택 화면(core:ui)이 내보낸 id 가 app NavHost 의 SavedStateHandle 전달을 지나 애프터노트 에디터 폼까지 그대로 도착하는지를 실제 NavHost 위에서 단언한다. 세 모듈에 걸친 경로라 각 모듈의 단위 테스트는 자기 마디까지만 본다 — 어긋나면 «다른 수신자가 지정되는» 회귀가 되는데 그 조합은 계측에서만 드러난다. - 완료는 고른 한 명의 id 만 돌려준다(고르지 않은 수신자는 폼에 들어오지 않는다) - 취소(뒤로가기)는 화면 안 선택을 버리고 기존 지정을 그대로 둔다 - 빈 목록·조회 실패는 완료를 잠그고, 재시도로 목록이 살아난 뒤에야 선택이 완료를 연다 변경은 androidTest 소스셋 한 파일뿐이고 main 소스는 0줄이다. --- .../ReceiverSelectionResultAndroidTest.kt | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt new file mode 100644 index 0000000000..d667a67192 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt @@ -0,0 +1,279 @@ +package com.afternote.afternote_fe + +import androidx.activity.compose.setContent +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.testing.TestNavHostController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.afternote.afternote_fe.navigation.AppNavigation +import com.afternote.afternote_fe.navigation.AppState +import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.model.user.Receiver +import com.afternote.core.ui.Route +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.domain.AfternoteType +import com.afternote.feature.afternote.presentation.author.navigation.model.AfternoteRoute +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 +import java.io.IOException +import javax.inject.Inject +import com.afternote.core.ui.R as CoreUiR +import com.afternote.feature.afternote.presentation.R as AfternoteR + +/** + * 공용 수신자 선택 화면과 소비 기능 사이의 **결과 전달** 계약 (#841). + * + * 공용 컴포넌트 자체의 상태 전이(`core:ui` [com.afternote.core.ui.receiver.ReceiverSelectScreen])는 + * 에뮬레이터 없이 도는 `ReceiverSelectScreenTest` 가 이미 고정한다. 여기서 덮는 것은 그 단위 + * 테스트가 닿지 못하는 구간 — **실제 NavHost 를 지나 소비 route 로 돌아오는 값**이다. + * + * 경로는 세 모듈에 걸쳐 있다. + * 1. `core:ui` 공용 화면이 선택된 수신자 id 를 완료 콜백으로 내보내고, + * 2. app 모듈의 `popBackWithSelectedReceiver` 가 그 id 를 **직전 back stack entry**(에디터)의 + * `SavedStateHandle` 에 `SELECTED_RECEIVER_ID_KEY` 로 쓰고 pop 하며, + * 3. `feature:afternote` 에디터가 복귀 시 그 id 를 이름·관계로 해석해 폼에 넣는다. + * + * 어느 한 마디만 어긋나도 «다른 수신자가 지정되는» 회귀가 되는데, 각 모듈의 단위 테스트는 + * 자기 마디까지만 본다. 그래서 이 계약은 계측 테스트로만 단언할 수 있다. + * + * 소비처는 현재 애프터노트 에디터 하나다(0830 `origin/develop` 실측 — 설정은 #631 로 관리 화면이 + * 되며 공용 컴포넌트 소비를 그만두고, 타임레터·마음의 기록은 아직 각 기능 전용 구현이다). + * 소비처가 늘어나면 각 모듈이 자기 route 테스트를 여기 옆에 더한다. + */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class ReceiverSelectionResultAndroidTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @get:Rule(order = 2) + val failureArtifactRule = + FailureArtifactRule { + composeRule.onRoot().captureToImage().asAndroidBitmap() + } + + @Inject + lateinit var userRepository: UserRepository + + private lateinit var navController: TestNavHostController + private val receiverSource = StagedReceiverSource() + + @Before + fun setUp() { + hiltRule.inject() + // 수신자 목록은 에디터 진입 시점과 선택 화면 진입 시점에 서로 다르게 응답해야 한다 — + // 그래야 "선택 화면에서 새로 고른 한 명"과 "에디터가 이미 채워 둔 사람"이 구분된다. + (userRepository as FakeUserRepository).onGetReceivers = { receiverSource.load() } + + composeRule.activityRule.scenario.onActivity { activity -> + navController = + TestNavHostController(activity).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + } + activity.setContent { + AfternoteTheme { + AppNavigation( + startDestination = Route.Afternote, + appState = AppState(navController), + ) + } + } + } + } + + /** + * 완료가 돌려주는 것은 **고른 그 한 명의 id** 다. + * + * 목록 순서(index)나 목록 전체가 아니라 id 하나가 건너가는지를, 고르지 않은 수신자가 폼에 + * 들어오지 않는 것으로 판정한다. + */ + @Test + fun afternoteEditorNavHost_receiverSelectConfirmAddsOnlyTheChosenReceiverToEditorForm() { + receiverSource.receivers = listOf(KIM) + openNewSocialEditor() + waitForEditorReceiver(KIM.name) + composeRule.onNodeWithText(PARK.name).assertDoesNotExist() + + receiverSource.receivers = listOf(KIM, PARK, LEE) + openReceiverSelect() + composeRule.onNodeWithText(copy(CoreUiR.string.core_ui_receiver_select_title)).assertIsDisplayed() + composeRule.onNodeWithText(confirmText).assertIsNotEnabled() + composeRule.onNodeWithText(PARK.name).performClick() + composeRule.onNodeWithText(confirmText).assertIsEnabled().performClick() + + waitForRoute() + waitForEditorReceiver(PARK.name) + // 이미 지정돼 있던 수신자는 유지되고, 고르지 않은 수신자는 따라 들어오지 않는다. + composeRule.onNodeWithText(KIM.name).performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText(LEE.name).assertDoesNotExist() + } + + /** 취소(뒤로가기)는 화면 안에서 고른 값을 버리고 소비 화면의 기존 지정을 그대로 둔다. */ + @Test + fun afternoteEditorNavHost_receiverSelectBackKeepsEditorReceiversUnchanged() { + receiverSource.receivers = listOf(KIM) + openNewSocialEditor() + waitForEditorReceiver(KIM.name) + + receiverSource.receivers = listOf(KIM, PARK) + openReceiverSelect() + composeRule.onNodeWithText(PARK.name).performClick() + composeRule + .onNodeWithContentDescription(copy(CoreUiR.string.core_ui_content_description_back)) + .performClick() + + waitForRoute() + waitForEditorReceiver(KIM.name) + composeRule.onNodeWithText(PARK.name).assertDoesNotExist() + } + + /** + * 목록을 못 그리는 상태(빈 목록·조회 실패)에서는 완료가 잠겨 있고, 재시도로 목록이 살아난 뒤에야 + * 선택이 완료를 연다. + * + * 상태 화면은 소비 기능이 소유하고(`listReplacement`) 완료 버튼은 공용 컴포넌트가 소유한다 — + * 둘이 실제로 한 화면에 조립됐을 때의 조합은 여기서만 드러난다. + */ + @Test + fun afternoteSelectReceiver_emptyAndLoadFailureBlockConfirmUntilRetryLoadsList() { + receiverSource.receivers = emptyList() + openNewSocialEditor() + + openReceiverSelect() + composeRule.onNodeWithText(copy(AfternoteR.string.afternote_select_receiver_empty)).assertIsDisplayed() + composeRule.onNodeWithText(confirmText).assertIsNotEnabled() + composeRule + .onNodeWithContentDescription(copy(CoreUiR.string.core_ui_content_description_back)) + .performClick() + waitForRoute() + + receiverSource.failing = true + openReceiverSelect() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule + .onAllNodesWithText(copy(AfternoteR.string.afternote_select_receiver_load_failed)) + .fetchSemanticsNodes() + .isNotEmpty() + } + composeRule.onNodeWithText(confirmText).assertIsNotEnabled() + + receiverSource.failing = false + receiverSource.receivers = listOf(KIM, PARK) + composeRule.onNodeWithText(copy(AfternoteR.string.afternote_select_receiver_retry)).performClick() + + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodesWithText(KIM.name).fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText(confirmText).assertIsNotEnabled() + composeRule.onNodeWithText(PARK.name).performClick() + composeRule.onNodeWithText(confirmText).assertIsEnabled() + } + + private val confirmText: String + get() = copy(CoreUiR.string.core_ui_receiver_select_confirm) + + private fun openNewSocialEditor() { + waitForRoute() + composeRule.runOnIdle { + navController.navigate( + AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.SOCIAL_NETWORK), + ) + } + waitForRoute() + waitForEditorAddButtons() + } + + /** + * 수신자 지정 섹션의 추가 버튼을 눌러 선택 화면으로 간다. + * + * 계정 카테고리 폼에는 같은 "추가" 설명을 가진 버튼이 수신자 지정·처리 방법 두 곳에 있고, + * 시안 순서상 수신자 지정이 앞이라 첫 번째가 대상이다. 순서가 바뀌면 라우트 대기에서 즉시 실패한다. + */ + private fun openReceiverSelect() { + waitForEditorAddButtons() + composeRule.onAllNodesWithContentDescription(addDescription()).run { + assertCountEquals(EDITOR_ADD_BUTTON_COUNT) + get(0).performScrollTo().performClick() + } + waitForRoute() + } + + private fun waitForEditorAddButtons() { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule + .onAllNodesWithContentDescription(addDescription()) + .fetchSemanticsNodes() + .size == EDITOR_ADD_BUTTON_COUNT + } + } + + private fun waitForEditorReceiver(name: String) { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodesWithText(name).fetchSemanticsNodes().isNotEmpty() + } + } + + private inline fun waitForRoute() { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + navController.currentDestination?.hasRoute() == true + } + } + + private fun addDescription(): String = copy(AfternoteR.string.afternote_editor_content_description_add) + + /** 화면 문구는 리소스가 정본이다 — 문구가 바뀌어도 단언이 따라간다 (#567). */ + private fun copy(resId: Int): String = + InstrumentationRegistry + .getInstrumentation() + .targetContext + .getString(resId) + + /** 조회 시점마다 응답을 갈아끼우는 수신자 목록 소스. */ + private class StagedReceiverSource { + @Volatile + var receivers: List = emptyList() + + @Volatile + var failing: Boolean = false + + fun load(): List { + if (failing) throw IOException("수신자 목록 조회 실패(테스트)") + return receivers + } + } + + private companion object { + const val TIMEOUT_MILLIS = 10_000L + + /** 계정 카테고리 에디터의 "추가" 버튼 — 수신자 지정 + 처리 방법 리스트. */ + const val EDITOR_ADD_BUTTON_COUNT = 2 + + val KIM = Receiver(receiverId = 7L, name = "김수신", relation = "가족", authCode = "fake-auth-7") + val PARK = Receiver(receiverId = 11L, name = "박친구", relation = "친구", authCode = "fake-auth-11") + val LEE = Receiver(receiverId = 23L, name = "이지인", relation = "지인", authCode = "fake-auth-23") + } +} From 7eb23fa20ec626a07246e07588ea712f5077d44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 30 Aug 2026 21:19:47 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(platform):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=EC=84=A0=ED=83=9D=20=EA=B3=84=EC=B8=A1=EC=9D=98=20?= =?UTF-8?q?=EB=AC=B4=EC=9D=98=EB=AF=B8=ED=95=9C=20=EB=8B=A8=EC=96=B8=20?= =?UTF-8?q?=EB=91=90=20=EA=B3=B3=EC=9D=84=20=EB=B0=B0=EB=A6=AC=EC=96=B4?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A0=EC=B9=9C=EB=8B=A4=20(#841)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 단언이 회귀를 못 잡는 상태였다. 빈 목록 문구는 조회 전 초기 상태에서도 그려진다 — SelectReceiverUiState() 기본값이 isLoading=false, receivers=[] 라 화면 분기가 그대로 빈 목록으로 떨어진다. 그래서 "빈 목록 문구가 보인다" 는 조회가 일어났는지조차 말해 주지 않았다. 조회를 게이트로 붙잡아 로딩 표시가 문구를 대신하고 있는 것을 먼저 확인하고, 풀어 준 뒤에 단언한다. 취소 경로의 배리어로 쓰던 수신자 이름은 선택 화면 목록에도 있어 화면 전환을 가르지 못했다. 게다가 destination 은 popBackStack 순간 뒤집혀 에디터 조립 전에 통과한다. 전환 판정을 에디터에만 있는 "추가" 버튼으로 바꾸고, 부정 단언 앞에 저장소 왕복을 강제로 완주시킨다 — 흘린 선택의 반영도 같은 홉을 지나므로 단언 뒤에 착지할 수 없다. 라우트 대기 실패에 현재 destination 을 실어, 잘못된 버튼을 눌렀을 때 원인이 드러나게 했다. --- .../ReceiverSelectionResultAndroidTest.kt | 162 +++++++++++++++--- 1 file changed, 135 insertions(+), 27 deletions(-) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt index d667a67192..00fafcea17 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt @@ -2,11 +2,14 @@ package com.afternote.afternote_fe import androidx.activity.compose.setContent import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.test.ComposeTimeoutException import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.hasProgressBarRangeInfo import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithText @@ -32,11 +35,13 @@ import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.presentation.author.navigation.model.AfternoteRoute import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.CompletableDeferred import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import java.io.IOException +import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import com.afternote.core.ui.R as CoreUiR import com.afternote.feature.afternote.presentation.R as AfternoteR @@ -60,6 +65,18 @@ import com.afternote.feature.afternote.presentation.R as AfternoteR * 소비처는 현재 애프터노트 에디터 하나다(0830 `origin/develop` 실측 — 설정은 #631 로 관리 화면이 * 되며 공용 컴포넌트 소비를 그만두고, 타임레터·마음의 기록은 아직 각 기능 전용 구현이다). * 소비처가 늘어나면 각 모듈이 자기 route 테스트를 여기 옆에 더한다. + * + * **배리어 규약.** `navController` 의 destination 은 `popBackStack()` 순간 뒤집히지만 화면 조립은 + * 그 뒤에 따라온다. 그래서 라우트 대기만으로는 "선택 화면이 사라졌다" 를 보장하지 못한다. + * 화면 전환 판정은 항상 [waitForEditorAddButtons] 로 한다 — "추가" 설명이 붙은 버튼은 에디터에만 + * 있고 선택 화면엔 없다. 부정 단언("들어오지 않았다") 앞에는 추가로 [awaitReceiverLoad] 로 + * 저장소를 한 바퀴 돌린다. 선택 결과 반영도 같은 저장소 홉(`resolveSelectedReceiver`)을 지나므로, + * 그 홉에 IO 나 지연이 생겨도 단언 뒤로 착지할 수 없다. + * + * **진입 경로 주의.** [Route.Afternote] 의 시작 화면은 지문 로그인이고, 계측에 주입되는 + * `FakeUserProfileCacheRepository` 가 패스키 미등록(false)을 내야 홈으로 자동 통과한다. + * 그 fake 기본값이 바뀌면 세 테스트가 모두 홈 대기에서 멈추므로, 실패 메시지에 현재 destination 을 + * 실어 원인이 드러나게 했다([waitForRoute]). */ @HiltAndroidTest @RunWith(AndroidJUnit4::class) @@ -125,7 +142,9 @@ class ReceiverSelectionResultAndroidTest { composeRule.onNodeWithText(PARK.name).performClick() composeRule.onNodeWithText(confirmText).assertIsEnabled().performClick() - waitForRoute() + waitForRoute("에디터") + // 선택 화면에도 세 사람이 모두 떠 있으므로, 에디터가 조립된 뒤에야 이름으로 판정할 수 있다. + waitForEditorAddButtons() waitForEditorReceiver(PARK.name) // 이미 지정돼 있던 수신자는 유지되고, 고르지 않은 수신자는 따라 들어오지 않는다. composeRule.onNodeWithText(KIM.name).performScrollTo().assertIsDisplayed() @@ -142,42 +161,61 @@ class ReceiverSelectionResultAndroidTest { receiverSource.receivers = listOf(KIM, PARK) openReceiverSelect() composeRule.onNodeWithText(PARK.name).performClick() + + // 복귀 직후 판정은 «취소가 값을 흘렸다» 를 놓칠 수 있다 — 흘린 값의 반영은 저장소를 한 번 + // 더 지나기 때문이다. 그 홉을 게이트로 붙잡았다 풀어 반드시 먼저 착지하게 만든 뒤 판정한다. + val gate = receiverSource.gateNextLoad() composeRule .onNodeWithContentDescription(copy(CoreUiR.string.core_ui_content_description_back)) .performClick() - waitForRoute() - waitForEditorReceiver(KIM.name) + waitForRoute("에디터") + waitForEditorAddButtons() + releaseGatedLoad(gate) + + composeRule.onNodeWithText(KIM.name).performScrollTo().assertIsDisplayed() composeRule.onNodeWithText(PARK.name).assertDoesNotExist() } /** - * 목록을 못 그리는 상태(빈 목록·조회 실패)에서는 완료가 잠겨 있고, 재시도로 목록이 살아난 뒤에야 - * 선택이 완료를 연다. + * 목록을 못 그리는 상태(로딩·빈 목록·조회 실패)에서는 완료가 잠겨 있고, 재시도로 목록이 + * 살아난 뒤에야 선택이 완료를 연다. * - * 상태 화면은 소비 기능이 소유하고(`listReplacement`) 완료 버튼은 공용 컴포넌트가 소유한다 — - * 둘이 실제로 한 화면에 조립됐을 때의 조합은 여기서만 드러난다. + * 빈 목록 문구는 **조회 전 초기 상태에서도** 그려진다(`SelectReceiverUiState()` 기본값이 + * `isLoading=false, receivers=[]` 라 화면 분기가 빈 목록으로 떨어진다). 그래서 "빈 목록 문구가 + * 보인다" 만으로는 조회가 일어났는지조차 알 수 없다. 조회를 게이트로 붙잡아 **로딩 표시가 + * 빈 목록 문구를 대신하고 있는 것**을 먼저 확인하고, 풀어 준 뒤에 빈 목록 문구를 단언한다. */ @Test fun afternoteSelectReceiver_emptyAndLoadFailureBlockConfirmUntilRetryLoadsList() { receiverSource.receivers = emptyList() openNewSocialEditor() + val emptyLoad = receiverSource.gateNextLoad() openReceiverSelect() - composeRule.onNodeWithText(copy(AfternoteR.string.afternote_select_receiver_empty)).assertIsDisplayed() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule + .onAllNodes(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)) + .fetchSemanticsNodes() + .isNotEmpty() + } + composeRule.onNodeWithText(emptyText).assertDoesNotExist() + emptyLoad.barrier.complete(Unit) + + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodesWithText(emptyText).fetchSemanticsNodes().isNotEmpty() + } composeRule.onNodeWithText(confirmText).assertIsNotEnabled() composeRule .onNodeWithContentDescription(copy(CoreUiR.string.core_ui_content_description_back)) .performClick() - waitForRoute() + waitForRoute("에디터") + waitForEditorAddButtons() receiverSource.failing = true openReceiverSelect() composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - composeRule - .onAllNodesWithText(copy(AfternoteR.string.afternote_select_receiver_load_failed)) - .fetchSemanticsNodes() - .isNotEmpty() + composeRule.onAllNodesWithText(loadFailedText).fetchSemanticsNodes().isNotEmpty() } composeRule.onNodeWithText(confirmText).assertIsNotEnabled() @@ -196,14 +234,20 @@ class ReceiverSelectionResultAndroidTest { private val confirmText: String get() = copy(CoreUiR.string.core_ui_receiver_select_confirm) + private val emptyText: String + get() = copy(AfternoteR.string.afternote_select_receiver_empty) + + private val loadFailedText: String + get() = copy(AfternoteR.string.afternote_select_receiver_load_failed) + private fun openNewSocialEditor() { - waitForRoute() + waitForRoute("애프터노트 홈(지문 로그인 자동 통과)") composeRule.runOnIdle { navController.navigate( AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.SOCIAL_NETWORK), ) } - waitForRoute() + waitForRoute("에디터") waitForEditorAddButtons() } @@ -211,39 +255,70 @@ class ReceiverSelectionResultAndroidTest { * 수신자 지정 섹션의 추가 버튼을 눌러 선택 화면으로 간다. * * 계정 카테고리 폼에는 같은 "추가" 설명을 가진 버튼이 수신자 지정·처리 방법 두 곳에 있고, - * 시안 순서상 수신자 지정이 앞이라 첫 번째가 대상이다. 순서가 바뀌면 라우트 대기에서 즉시 실패한다. + * 시안 순서상 수신자 지정이 앞이라 첫 번째가 대상이다(개수 단언으로 그 전제를 고정한다). + * 순서가 바뀌면 라우트 대기가 현재 destination 을 실은 메시지로 실패한다. */ private fun openReceiverSelect() { waitForEditorAddButtons() - composeRule.onAllNodesWithContentDescription(addDescription()).run { + composeRule.onAllNodesWithContentDescription(addDescription).run { assertCountEquals(EDITOR_ADD_BUTTON_COUNT) get(0).performScrollTo().performClick() } - waitForRoute() + waitForRoute("수신자 선택 화면") } + /** + * 에디터가 조립됐음을 확정한다. "추가" 설명이 붙은 버튼은 에디터에만 있고 선택 화면엔 없어, + * 수신자 이름과 달리 두 화면을 구분하는 앵커가 된다. + */ private fun waitForEditorAddButtons() { composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { composeRule - .onAllNodesWithContentDescription(addDescription()) + .onAllNodesWithContentDescription(addDescription) .fetchSemanticsNodes() .size == EDITOR_ADD_BUTTON_COUNT } } + /** + * 게이트에 걸린 목록 조회가 **실제로 시작된 것**을 확인하고 풀어, 저장소 왕복 한 바퀴를 + * 완주시킨다. 시작·완료 모두 직전 값보다 커졌는지로 판정한다 — 그래야 배리어 자체가 + * 무조건 참인 조건으로 무너지지 않는다. + */ + private fun releaseGatedLoad(gate: GatedLoad) { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + receiverSource.loadsStarted.get() > gate.startedBefore + } + val completedBefore = receiverSource.loadsCompleted.get() + gate.barrier.complete(Unit) + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + receiverSource.loadsCompleted.get() > completedBefore + } + composeRule.waitForIdle() + } + private fun waitForEditorReceiver(name: String) { composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { composeRule.onAllNodesWithText(name).fetchSemanticsNodes().isNotEmpty() } } - private inline fun waitForRoute() { - composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { - navController.currentDestination?.hasRoute() == true + /** 실패 시 현재 destination 을 함께 남긴다 — 「10초 뒤 조건 미충족」만으론 원인이 안 드러난다. */ + private inline fun waitForRoute(description: String) { + try { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + navController.currentDestination?.hasRoute() == true + } + } catch (e: ComposeTimeoutException) { + throw AssertionError( + "$description 으로 이동하지 못했습니다. 현재 destination=${navController.currentDestination?.route}", + e, + ) } } - private fun addDescription(): String = copy(AfternoteR.string.afternote_editor_content_description_add) + private val addDescription: String + get() = copy(AfternoteR.string.afternote_editor_content_description_add) /** 화면 문구는 리소스가 정본이다 — 문구가 바뀌어도 단언이 따라간다 (#567). */ private fun copy(resId: Int): String = @@ -252,7 +327,12 @@ class ReceiverSelectionResultAndroidTest { .targetContext .getString(resId) - /** 조회 시점마다 응답을 갈아끼우는 수신자 목록 소스. */ + /** + * 조회 시점마다 응답을 갈아끼우는 수신자 목록 소스. + * + * [gateNextLoad] 로 다음 조회를 붙잡아 두면 로딩 상태를 관찰할 수 있고, 부정 단언 앞에 + * 저장소 왕복을 강제로 완주시키는 배리어로도 쓴다. + */ private class StagedReceiverSource { @Volatile var receivers: List = emptyList() @@ -260,12 +340,40 @@ class ReceiverSelectionResultAndroidTest { @Volatile var failing: Boolean = false - fun load(): List { - if (failing) throw IOException("수신자 목록 조회 실패(테스트)") - return receivers + val loadsStarted = AtomicInteger() + val loadsCompleted = AtomicInteger() + + @Volatile + private var gate: CompletableDeferred? = null + + fun gateNextLoad(): GatedLoad { + val barrier = CompletableDeferred() + val startedBefore = loadsStarted.get() + gate = barrier + return GatedLoad(barrier = barrier, startedBefore = startedBefore) + } + + suspend fun load(): List { + loadsStarted.incrementAndGet() + gate?.let { barrier -> + gate = null + barrier.await() + } + try { + if (failing) throw IOException("수신자 목록 조회 실패(테스트)") + return receivers + } finally { + loadsCompleted.incrementAndGet() + } } } + /** 붙잡아 둔 조회 한 건 — 푸는 쪽이 "언제부터"를 알아야 시작 판정이 무조건 참이 되지 않는다. */ + private class GatedLoad( + val barrier: CompletableDeferred, + val startedBefore: Int, + ) + private companion object { const val TIMEOUT_MILLIS = 10_000L From 24d1af3830d0771eff8627ffb586f94d527ee77f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 31 Aug 2026 13:41:30 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs(test):=20=EC=88=98=EC=8B=A0=EC=9E=90?= =?UTF-8?q?=20=EC=84=A0=ED=83=9D=20=EA=B2=B0=EA=B3=BC=20KDoc=EC=9D=84=20?= =?UTF-8?q?=EB=B3=B5=EC=88=98=20=EA=B3=84=EC=95=BD=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=98=EB=8B=A4=20(#841)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReceiverSelectionResultAndroidTest.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt index 00fafcea17..603abfc377 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt @@ -54,10 +54,11 @@ import com.afternote.feature.afternote.presentation.R as AfternoteR * 테스트가 닿지 못하는 구간 — **실제 NavHost 를 지나 소비 route 로 돌아오는 값**이다. * * 경로는 세 모듈에 걸쳐 있다. - * 1. `core:ui` 공용 화면이 선택된 수신자 id 를 완료 콜백으로 내보내고, - * 2. app 모듈의 `popBackWithSelectedReceiver` 가 그 id 를 **직전 back stack entry**(에디터)의 - * `SavedStateHandle` 에 `SELECTED_RECEIVER_ID_KEY` 로 쓰고 pop 하며, - * 3. `feature:afternote` 에디터가 복귀 시 그 id 를 이름·관계로 해석해 폼에 넣는다. + * 1. `core:ui` 공용 화면이 선택된 수신자 id 목록을 완료 콜백으로 내보내고, + * 2. app 모듈의 `popBackWithSelectedReceivers(List)` 가 그 목록을 + * **직전 back stack entry**(에디터)의 `SavedStateHandle` 에 + * `SELECTED_RECEIVER_IDS_KEY` (`LongArray`)로 쓰고 pop 하며, + * 3. `feature:afternote` 에디터가 복귀 시 그 id 목록을 이름·관계로 해석해 폼에 넣는다. * * 어느 한 마디만 어긋나도 «다른 수신자가 지정되는» 회귀가 되는데, 각 모듈의 단위 테스트는 * 자기 마디까지만 본다. 그래서 이 계약은 계측 테스트로만 단언할 수 있다. @@ -123,10 +124,10 @@ class ReceiverSelectionResultAndroidTest { } /** - * 완료가 돌려주는 것은 **고른 그 한 명의 id** 다. + * 완료가 돌려주는 것은 **고른 수신자 id 목록**이다. * - * 목록 순서(index)나 목록 전체가 아니라 id 하나가 건너가는지를, 고르지 않은 수신자가 폼에 - * 들어오지 않는 것으로 판정한다. + * 목록 순서(index)나 표시된 수신자 목록 전체가 아니라 고른 id 목록이 건너가는지를, + * 고르지 않은 수신자가 폼에 들어오지 않는 것으로 판정한다. */ @Test fun afternoteEditorNavHost_receiverSelectConfirmAddsOnlyTheChosenReceiverToEditorForm() {