Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,388 @@
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
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 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

/**
* 공용 수신자 선택 화면과 소비 기능 사이의 **결과 전달** 계약 (#841).
*
* 공용 컴포넌트 자체의 상태 전이(`core:ui` [com.afternote.core.ui.receiver.ReceiverSelectScreen])는
* 에뮬레이터 없이 도는 `ReceiverSelectScreenTest` 가 이미 고정한다. 여기서 덮는 것은 그 단위
* 테스트가 닿지 못하는 구간 — **실제 NavHost 를 지나 소비 route 로 돌아오는 값**이다.
*
* 경로는 세 모듈에 걸쳐 있다.
* 1. `core:ui` 공용 화면이 선택된 수신자 id 목록을 완료 콜백으로 내보내고,
* 2. app 모듈의 `popBackWithSelectedReceivers(List<Long>)` 가 그 목록을
* **직전 back stack entry**(에디터)의 `SavedStateHandle` 에
* `SELECTED_RECEIVER_IDS_KEY` (`LongArray`)로 쓰고 pop 하며,
* 3. `feature:afternote` 에디터가 복귀 시 그 id 목록을 이름·관계로 해석해 폼에 넣는다.
*
* 어느 한 마디만 어긋나도 «다른 수신자가 지정되는» 회귀가 되는데, 각 모듈의 단위 테스트는
* 자기 마디까지만 본다. 그래서 이 계약은 계측 테스트로만 단언할 수 있다.
*
* 소비처는 현재 애프터노트 에디터 하나다(0830 `origin/develop` 실측 — 설정은 #631 로 관리 화면이
* 되며 공용 컴포넌트 소비를 그만두고, 타임레터·마음의 기록은 아직 각 기능 전용 구현이다).
* 소비처가 늘어나면 각 모듈이 자기 route 테스트를 여기 옆에 더한다.
*
* **배리어 규약.** `navController` 의 destination 은 `popBackStack()` 순간 뒤집히지만 화면 조립은
* 그 뒤에 따라온다. 그래서 라우트 대기만으로는 "선택 화면이 사라졌다" 를 보장하지 못한다.
* 화면 전환 판정은 항상 [waitForEditorAddButtons] 로 한다 — "추가" 설명이 붙은 버튼은 에디터에만
* 있고 선택 화면엔 없다. 부정 단언("들어오지 않았다") 앞에는 추가로 [awaitReceiverLoad] 로
* 저장소를 한 바퀴 돌린다. 선택 결과 반영도 같은 저장소 홉(`resolveSelectedReceiver`)을 지나므로,
* 그 홉에 IO 나 지연이 생겨도 단언 뒤로 착지할 수 없다.
*
* **진입 경로 주의.** [Route.Afternote] 의 시작 화면은 지문 로그인이고, 계측에 주입되는
* `FakeUserProfileCacheRepository` 가 패스키 미등록(false)을 내야 홈으로 자동 통과한다.
* 그 fake 기본값이 바뀌면 세 테스트가 모두 홈 대기에서 멈추므로, 실패 메시지에 현재 destination 을
* 실어 원인이 드러나게 했다([waitForRoute]).
*/
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class ReceiverSelectionResultAndroidTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)

@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()

@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<AfternoteRoute.EditorRoute>("에디터")
// 선택 화면에도 세 사람이 모두 떠 있으므로, 에디터가 조립된 뒤에야 이름으로 판정할 수 있다.
waitForEditorAddButtons()
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()

// 복귀 직후 판정은 «취소가 값을 흘렸다» 를 놓칠 수 있다 — 흘린 값의 반영은 저장소를 한 번
// 더 지나기 때문이다. 그 홉을 게이트로 붙잡았다 풀어 반드시 먼저 착지하게 만든 뒤 판정한다.
val gate = receiverSource.gateNextLoad()
composeRule
.onNodeWithContentDescription(copy(CoreUiR.string.core_ui_content_description_back))
.performClick()

waitForRoute<AfternoteRoute.EditorRoute>("에디터")
waitForEditorAddButtons()
releaseGatedLoad(gate)

composeRule.onNodeWithText(KIM.name).performScrollTo().assertIsDisplayed()
composeRule.onNodeWithText(PARK.name).assertDoesNotExist()
}

/**
* 목록을 못 그리는 상태(로딩·빈 목록·조회 실패)에서는 완료가 잠겨 있고, 재시도로 목록이
* 살아난 뒤에야 선택이 완료를 연다.
*
* 빈 목록 문구는 **조회 전 초기 상태에서도** 그려진다(`SelectReceiverUiState()` 기본값이
* `isLoading=false, receivers=[]` 라 화면 분기가 빈 목록으로 떨어진다). 그래서 "빈 목록 문구가
* 보인다" 만으로는 조회가 일어났는지조차 알 수 없다. 조회를 게이트로 붙잡아 **로딩 표시가
* 빈 목록 문구를 대신하고 있는 것**을 먼저 확인하고, 풀어 준 뒤에 빈 목록 문구를 단언한다.
*/
@Test
fun afternoteSelectReceiver_emptyAndLoadFailureBlockConfirmUntilRetryLoadsList() {
receiverSource.receivers = emptyList()
openNewSocialEditor()

val emptyLoad = receiverSource.gateNextLoad()
openReceiverSelect()
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<AfternoteRoute.EditorRoute>("에디터")
waitForEditorAddButtons()

receiverSource.failing = true
openReceiverSelect()
composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) {
composeRule.onAllNodesWithText(loadFailedText).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 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<AfternoteRoute.AfternoteHomeRoute>("애프터노트 홈(지문 로그인 자동 통과)")
composeRule.runOnIdle {
navController.navigate(
AfternoteRoute.EditorFlowRoute(initialType = AfternoteType.SOCIAL_NETWORK),
)
}
waitForRoute<AfternoteRoute.EditorRoute>("에디터")
waitForEditorAddButtons()
}

/**
* 수신자 지정 섹션의 추가 버튼을 눌러 선택 화면으로 간다.
*
* 계정 카테고리 폼에는 같은 "추가" 설명을 가진 버튼이 수신자 지정·처리 방법 두 곳에 있고,
* 시안 순서상 수신자 지정이 앞이라 첫 번째가 대상이다(개수 단언으로 그 전제를 고정한다).
* 순서가 바뀌면 라우트 대기가 현재 destination 을 실은 메시지로 실패한다.
*/
private fun openReceiverSelect() {
waitForEditorAddButtons()
composeRule.onAllNodesWithContentDescription(addDescription).run {
assertCountEquals(EDITOR_ADD_BUTTON_COUNT)
get(0).performScrollTo().performClick()
}
waitForRoute<AfternoteRoute.SelectReceiverRoute>("수신자 선택 화면")
}

/**
* 에디터가 조립됐음을 확정한다. "추가" 설명이 붙은 버튼은 에디터에만 있고 선택 화면엔 없어,
* 수신자 이름과 달리 두 화면을 구분하는 앵커가 된다.
*/
private fun waitForEditorAddButtons() {
composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) {
composeRule
.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()
}
}

/** 실패 시 현재 destination 을 함께 남긴다 — 「10초 뒤 조건 미충족」만으론 원인이 안 드러난다. */
private inline fun <reified T : Any> waitForRoute(description: String) {
try {
composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) {
navController.currentDestination?.hasRoute<T>() == true
}
} catch (e: ComposeTimeoutException) {
throw AssertionError(
"$description 으로 이동하지 못했습니다. 현재 destination=${navController.currentDestination?.route}",
e,
)
}
}

private val addDescription: String
get() = copy(AfternoteR.string.afternote_editor_content_description_add)

/** 화면 문구는 리소스가 정본이다 — 문구가 바뀌어도 단언이 따라간다 (#567). */
private fun copy(resId: Int): String =
InstrumentationRegistry
.getInstrumentation()
.targetContext
.getString(resId)

/**
* 조회 시점마다 응답을 갈아끼우는 수신자 목록 소스.
*
* [gateNextLoad] 로 다음 조회를 붙잡아 두면 로딩 상태를 관찰할 수 있고, 부정 단언 앞에
* 저장소 왕복을 강제로 완주시키는 배리어로도 쓴다.
*/
private class StagedReceiverSource {
@Volatile
var receivers: List<Receiver> = emptyList()

@Volatile
var failing: Boolean = false

val loadsStarted = AtomicInteger()
val loadsCompleted = AtomicInteger()

@Volatile
private var gate: CompletableDeferred<Unit>? = null

fun gateNextLoad(): GatedLoad {
val barrier = CompletableDeferred<Unit>()
val startedBefore = loadsStarted.get()
gate = barrier
return GatedLoad(barrier = barrier, startedBefore = startedBefore)
}

suspend fun load(): List<Receiver> {
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<Unit>,
val startedBefore: Int,
)

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")
}
}