diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt index 711c00e8cc..20b113c583 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt @@ -27,6 +27,7 @@ import org.junit.Test import org.junit.runner.RunWith import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference +import com.afternote.core.ui.R as CoreUiR import com.afternote.feature.afternote.presentation.R as AfternoteR /** @@ -113,7 +114,7 @@ class AfternoteHomeRefreshBannerAndroidTest { // 배너의 «다시 시도» 가 같은 목록을 복구하고 배너를 걷는다. failing.set(false) - composeRule.onNodeWithText(retryCopy).performClick() + composeRule.onNodeWithText(bannerRetryCopy).performClick() awaitTextGone(bannerCopy) composeRule.onNodeWithText(FIRST_ITEM_NAME).assertIsDisplayed() composeRule.onNodeWithText(fullErrorCopy).assertDoesNotExist() @@ -145,7 +146,8 @@ class AfternoteHomeRefreshBannerAndroidTest { .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 bannerCopy: String get() = copy(CoreUiR.string.core_ui_list_refresh_error) + private val bannerRetryCopy: String get() = copy(CoreUiR.string.core_ui_list_refresh_retry) private val retryCopy: String get() = copy(AfternoteR.string.afternote_home_retry) private companion object { 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 693de0ccbf..07e2cd1e76 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt @@ -249,7 +249,7 @@ class ReceiverSelectionResultAndroidTest { receiverSource.failing = false receiverSource.receivers = listOf(KIM, PARK) - composeRule.onNodeWithText(copy(AfternoteR.string.afternote_select_receiver_retry)).performClick() + composeRule.onNodeWithText(copy(CoreUiR.string.core_ui_receiver_list_retry)).performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { composeRule.onAllNodesWithText(KIM.name).fetchSemanticsNodes().isNotEmpty() @@ -266,7 +266,7 @@ class ReceiverSelectionResultAndroidTest { get() = copy(AfternoteR.string.afternote_select_receiver_empty) private val loadFailedText: String - get() = copy(AfternoteR.string.afternote_select_receiver_load_failed) + get() = copy(CoreUiR.string.core_ui_receiver_list_load_failed) /** * 애프터노트 홈의 연필 FAB 으로 계정 카테고리 에디터를 연다. diff --git a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt index 8e7c2cc0b4..944efe020c 100644 --- a/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt +++ b/core/data/src/main/java/com/afternote/core/data/repoimpl/UserReceiverRepositoryImpl.kt @@ -6,6 +6,7 @@ import com.afternote.core.data.mapper.delivery.toRequestDto import com.afternote.core.data.mapper.user.toDomain import com.afternote.core.datastore.TokenDataSource import com.afternote.core.domain.error.ReceiverRequestRejectedException +import com.afternote.core.domain.model.ReceiverListState import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.ReceiverDeliveryConditions @@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update import javax.inject.Inject @@ -59,7 +61,7 @@ internal class UserReceiverRepositoryImpl private val receiverRefreshRevision = MutableStateFlow(0L) // 조회 실패를 예외로 흘리면 구독 중인 화면이 미처리 예외로 죽는다. 일반적인 일시 실패는 같은 - // 로그인 구간에서 이 collector가 마지막으로 성공한 목록으로 낮춘다. 캐시를 flow 안에 두는 이유는 + // 로그인 구간에서 이 collector가 마지막으로 성공한 목록을 실은 Failure 로 낸다. 캐시를 flow 안에 두는 이유는 // 저장소 인스턴스보다 수명이 짧은 «로그인 구간 + collector»에 귀속해 계정 사이에 섞이지 않게 하기 위함이다. // // 그 로그인 구간을 [TokenDataSource.sessionId] 로 가른다. 로그인 여부 Boolean 으로 가르면 @@ -69,8 +71,11 @@ internal class UserReceiverRepositoryImpl // // 이전 세션 추적은 수집자마다 새로 만든다. 이 저장소는 @Singleton 이라 필드에 두면 한 화면의 세션 // 전환 판정을 다른 화면이 먹는다. + // + // 목록 전용 [receiverListFlow] 도 이 Flow 에서 파생한다 (#2045). 세션 분기와 늦은 결과 폐기를 한 곳에만 + // 둬야 두 공개 Flow 의 격리 규칙이 갈라지지 않는다. @OptIn(ExperimentalCoroutinesApi::class) - override val receiverListFlow: Flow> = + override val receiverListStateFlow: Flow = flow { var previousSessionId: String? = null emitAll( @@ -80,7 +85,7 @@ internal class UserReceiverRepositoryImpl val previous = previousSessionId previousSessionId = sessionId when { - sessionId == null -> flowOf(emptyList()) + sessionId == null -> flowOf(ReceiverListState.SignedOut) // 첫 구독과 로그아웃 뒤 로그인. 이 수집자가 들고 있는 값이 이전 계정 목록이 // 아니므로 곧바로 조회한다. 여기서까지 빈 목록을 먼저 내면 첫 구독을 @@ -90,17 +95,36 @@ internal class UserReceiverRepositoryImpl // 실제 세션 교체. 하류 stateIn 의 initialValue 는 내부 flow 가 교체돼도 // 되돌아가지 않으므로, 새 세션 조회가 끝날 때까지 이전 계정 목록이 수집자의 // 현재 값으로 남는다. 계약이 "새 세션에는 빈 목록" 이라 조회를 기다리지 않고 - // 먼저 비운다. - else -> receiverListForSession(sessionId).onStart { emit(emptyList()) } + // 먼저 비운다. 저장소에서 합쳐져 사라진 중간 로그아웃 방출을 되살리는 셈이다. + else -> receiverListForSession(sessionId).onStart { emit(ReceiverListState.SignedOut) } } }, ) } - private fun receiverListForSession(sessionId: String): Flow> = + // 기존 목록 전용 구독자의 방출 순서를 그대로 지킨다. 로딩은 방출을 만들지 않고, 실패는 이 구독이 같은 + // 세션에서 마지막으로 성공한 목록(없거나 401 로 버렸으면 빈 목록)으로 낮춘다. + override val receiverListFlow: Flow> = + receiverListStateFlow.mapNotNull { state -> + when (state) { + is ReceiverListState.Loading -> null + is ReceiverListState.Success -> state.receivers + is ReceiverListState.Failure -> state.previousReceivers.orEmpty() + ReceiverListState.SignedOut -> emptyList() + } + } + + override fun refreshReceiverList() { + receiverRefreshRevision.update { it + 1 } + } + + private fun receiverListForSession(sessionId: String): Flow = flow { - var lastKnownReceivers = emptyList() + var lastKnownReceivers: List? = null receiverRefreshRevision.collect { + // 로딩에는 이 구독이 이 세션에서 이미 낸 성공 목록만 싣는다. 수집자가 이미 받은 값 밖의 것이 + // 새지 않으므로 아래의 세션 재확인은 여기에 걸지 않는다. + emit(ReceiverListState.Loading(lastKnownReceivers, sessionId)) val outcome = runCatchingCancellable { getReceivers() } .onFailure { @@ -120,19 +144,19 @@ internal class UserReceiverRepositoryImpl // 않는다. 이 비교와 방출이 원자적인 것은 아니며, 이미 내보낸 값을 되돌리지는 못한다. if (tokenDataSource.sessionId.first() != sessionId) return@collect - val receivers = + val state = outcome.fold( - onSuccess = { it }, + onSuccess = { receivers -> + lastKnownReceivers = receivers + ReceiverListState.Success(receivers) + }, onFailure = { failure -> - if (failure is ApiException && failure.status == UNAUTHORIZED_STATUS) { - emptyList() - } else { - lastKnownReceivers - } + val discardPrevious = failure is ApiException && failure.status == UNAUTHORIZED_STATUS + if (discardPrevious) lastKnownReceivers = null + ReceiverListState.Failure(lastKnownReceivers, discardPrevious) }, ) - lastKnownReceivers = receivers - emit(receivers) + emit(state) } } diff --git a/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverListStateFlowTest.kt b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverListStateFlowTest.kt new file mode 100644 index 0000000000..4e4bb7e4cd --- /dev/null +++ b/core/data/src/test/java/com/afternote/core/data/repoimpl/ReceiverListStateFlowTest.kt @@ -0,0 +1,477 @@ +package com.afternote.core.data.repoimpl + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.model.ReceiverListState +import com.afternote.core.domain.repository.UserReceiverRepository +import com.afternote.core.model.user.Receiver +import com.afternote.core.network.dto.DeletePushTokenRequestDto +import com.afternote.core.network.dto.PushTokenDto +import com.afternote.core.network.dto.ReceiverDetailDto +import com.afternote.core.network.dto.ReceiverListDto +import com.afternote.core.network.dto.RegisterPushTokenRequestDto +import com.afternote.core.network.dto.SocialAccountLinkRequestDto +import com.afternote.core.network.dto.UserConnectedAccountDto +import com.afternote.core.network.dto.UserCreateReceiverDto +import com.afternote.core.network.dto.UserCreateReceiverRequestDto +import com.afternote.core.network.dto.UserDto +import com.afternote.core.network.dto.UserMarketingConsentDto +import com.afternote.core.network.dto.UserPatchReceiverDto +import com.afternote.core.network.dto.UserPatchReceiverRequestDto +import com.afternote.core.network.dto.UserPushSettingDto +import com.afternote.core.network.dto.UserUpdateMarketingConsentRequestDto +import com.afternote.core.network.dto.UserUpdateProfileRequestDto +import com.afternote.core.network.dto.UserUpdatePushSettingRequestDto +import com.afternote.core.network.dto.UserUpdateReceiverMessageRequestDto +import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionDto +import com.afternote.core.network.dto.delivery.ReceiverDeliveryConditionUpdateRequestDto +import com.afternote.core.network.model.ApiException +import com.afternote.core.network.model.BaseResponse +import com.afternote.core.network.service.UserApiService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.net.UnknownHostException +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger + +/** + * 수신자 목록 조회 결과 계약 (#2045). + * + * [UserReceiverRepository.receiverListStateFlow] 가 로딩·성공·실패·세션 종료를 가르는지, 그리고 같은 원천에서 + * 파생한 [UserReceiverRepository.receiverListFlow] 가 #2135 의 방출 순서를 그대로 지키는지 공개 계약으로만 본다. + * 세션 경계는 실제 파일 DataStore 위의 [TestTokenSessionStore] 로 세운다. 기존 목록 전용 테스트 + * (`UserRepositoryImplTest`·[ReceiverSessionBoundaryRaceTest]·[ReceiverUpdateRefreshTest])는 수정 없이 통과해야 한다. + */ +class ReceiverListStateFlowTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val sessionStore by lazy { TestTokenSessionStore(temporaryFolder.root) } + private val fetches = ScriptedFetches() + private val reporter = RecordingErrorReporter() + + @Before + fun openSession() = runBlocking { sessionStore.login(accessToken = "access-a", refreshToken = "refresh-a") } + + @After + fun closeSessionStore() = sessionStore.close() + + @Test + fun `receiverListStateFlow - 첫 조회는 로딩 뒤 결과를 내고 성공한 0건은 빈 Success 다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + try { + assertEquals(loading(null), states.next()) + fetches.awaitStarted() + fetches.respond() + assertEquals(ReceiverListState.Success(emptyList()), states.next()) + } finally { + states.stop() + } + } + + @Test + fun `receiverListStateFlow - 첫 실패는 빈 성공과 구별되고 같은 구독이 refreshReceiverList 로 다시 조회한다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + try { + assertEquals(loading(null), states.next()) + fetches.awaitStarted() + fetches.fail(UnknownHostException("오프라인")) + assertEquals(ReceiverListState.Failure(null, discardPrevious = false), states.next()) + + repository.refreshReceiverList() + + assertEquals(loading(null), states.next()) + fetches.awaitStarted() + fetches.respond(RECEIVER_A) + assertEquals(ReceiverListState.Success(listOf(receiver(RECEIVER_A))), states.next()) + assertEquals(2, fetches.startedCount) + assertEquals(1, reporter.failures.size) + } finally { + states.stop() + } + } + + @Test + fun `receiverListStateFlow - 갱신 로딩과 갱신 실패는 마지막 성공 목록을 싣는다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + try { + states.succeedFirst(RECEIVER_A) + + repository.refreshReceiverList() + + val previous = listOf(receiver(RECEIVER_A)) + assertEquals(loading(previous), states.next()) + fetches.awaitStarted() + fetches.fail(ApiException(status = 500, code = 500, serverMessage = null, fallbackMessage = "서버 오류")) + assertEquals(ReceiverListState.Failure(previous, discardPrevious = false), states.next()) + } finally { + states.stop() + } + } + + @Test + fun `receiverListStateFlow - 401 은 마지막 성공 목록을 버리고 다음 실패에도 되살리지 않는다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + try { + states.succeedFirst(RECEIVER_A) + + repository.refreshReceiverList() + assertEquals(loading(listOf(receiver(RECEIVER_A))), states.next()) + fetches.awaitStarted() + fetches.fail(ApiException(status = 401, code = 401, serverMessage = null, fallbackMessage = "인증 만료")) + assertEquals(ReceiverListState.Failure(null, discardPrevious = true), states.next()) + + repository.refreshReceiverList() + assertEquals(loading(null), states.next()) + fetches.awaitStarted() + fetches.fail(UnknownHostException("오프라인")) + assertEquals(ReceiverListState.Failure(null, discardPrevious = false), states.next()) + } finally { + states.stop() + } + } + + /** + * 앱에서 401 은 `TokenAuthenticator` 가 재발급 거절을 확정하고 세션을 비운 뒤에야 조회 예외로 올라온다 + * (`TokenReissuer` 가 락 안에서 정리, #1126). 그래서 이 경로에서는 Failure(null) 가 나오지 않고, 소비자는 + * SignedOut 으로 이전 계정 행을 비운다. + */ + @Test + fun `receiverListStateFlow - 세션을 비운 뒤 올라온 401 은 Failure 없이 SignedOut 만 낸다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + try { + states.succeedFirst(RECEIVER_A) + sessionStore.holdObservation() + + repository.refreshReceiverList() + assertEquals(loading(listOf(receiver(RECEIVER_A))), states.next()) + fetches.awaitStarted() + sessionStore.clearSession() + fetches.fail(ApiException(status = 401, code = 401, serverMessage = null, fallbackMessage = "인증 만료")) + assertNull("세션을 비운 뒤의 401 이 Failure 로 나왔다", states.nextOrNull()) + + sessionStore.releaseObservation() + assertEquals(ReceiverListState.SignedOut, states.next()) + assertNull("SignedOut 뒤에 상태가 더 나왔다", states.nextOrNull()) + } finally { + states.stop() + } + } + + @Test + fun `receiverListStateFlow - 로그인 세션이 없으면 서버를 부르지 않고 SignedOut 을 낸다`() = + runBlocking { + sessionStore.clearSession() + val repository = repository() + + assertEquals(ReceiverListState.SignedOut, repository.receiverListStateFlow.first()) + assertEquals(0, fetches.startedCount) + } + + /** + * 수집자가 중간 로그아웃 방출을 못 본 채 새 세션만 받는 구간 (#2135). 상태 Flow 는 새 세션의 로딩보다 + * 먼저 SignedOut 을 내야 한다. Loading(null) 부터 내면 소비자는 이전 계정 행을 계속 들고 있어도 된다고 읽는다. + */ + @Test + fun `receiverListStateFlow - 로그아웃 방출 없이 세션이 바뀌어도 새 세션 로딩 전에 SignedOut 을 먼저 낸다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + try { + states.succeedFirst(RECEIVER_A) + + sessionStore.holdObservation() + sessionStore.clearSession() + sessionStore.login(accessToken = "access-b", refreshToken = "refresh-b") + sessionStore.releaseObservation() + + assertEquals(ReceiverListState.SignedOut, states.next()) + assertEquals(loading(null), states.next()) + fetches.awaitStarted() + fetches.respond(RECEIVER_B) + assertEquals(ReceiverListState.Success(listOf(receiver(RECEIVER_B))), states.next()) + } finally { + states.stop() + } + } + + /** + * 세션 전환이 저장소에는 커밋됐지만 상위 관측이 아직 새 식별자를 전달하지 못한 구간에 끝난 조회는 + * 목록 Flow 와 마찬가지로 상태 Flow 에도 결과를 내지 않는다 (#2135 의 늦은 결과 가드). + */ + @Test + fun `receiverListStateFlow - 이전 세션의 늦은 조회 결과는 성공도 실패도 내지 않는다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + try { + states.succeedFirst(RECEIVER_A) + val previousSessionLoading = loading(listOf(receiver(RECEIVER_A))) + sessionStore.holdObservation() + + repository.refreshReceiverList() + assertEquals(previousSessionLoading, states.next()) + fetches.awaitStarted() + sessionStore.clearSession() + sessionStore.login(accessToken = "access-b", refreshToken = "refresh-b") + fetches.fail(UnknownHostException("이전 세션 조회 실패")) + assertNull("이전 세션 조회의 실패가 상태로 나왔다", states.nextOrNull()) + + repository.refreshReceiverList() + assertEquals(previousSessionLoading, states.next()) + fetches.awaitStarted() + fetches.respond(RECEIVER_A) + assertNull("이전 세션 조회의 늦은 성공이 상태로 나왔다", states.nextOrNull()) + + sessionStore.releaseObservation() + assertEquals(ReceiverListState.SignedOut, states.next()) + assertEquals(loading(null), states.next()) + fetches.awaitStarted() + fetches.respond(RECEIVER_B) + assertEquals(ReceiverListState.Success(listOf(receiver(RECEIVER_B))), states.next()) + } finally { + states.stop() + } + } + + @Test + fun `receiverListStateFlow - 조회 중 구독을 취소하면 Failure 도 실패 리포트도 남기지 않는다`() = + runBlocking { + val repository = repository() + val states = collectStates(repository) + + assertEquals(loading(null), states.next()) + fetches.awaitStarted() + states.stop() + + assertNull(states.nextOrNull()) + assertEquals(emptyList(), reporter.failures.toList()) + } + + /** + * 목록 전용 소비자(타임레터 수신인 목록 등)의 방출 순서 보존 조건. 상태 Flow 가 재조회마다 Loading 을 더 내도 + * 목록 Flow 에는 새 방출이 생기지 않아야 한다. 생기면 기존 소비자가 조회 중에 빈 목록이나 같은 목록을 한 번 더 받는다. + */ + @Test + fun `receiverListFlow - 상태 Flow 에서 파생돼도 재조회 로딩은 방출을 만들지 않는다`() = + runBlocking { + val repository = repository() + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { repository.receiverListFlow.collect { emissions.send(it) } } + + try { + fetches.awaitStarted() + assertNull("조회 결과 전에 목록이 나왔다", emissions.tryReceive().getOrNull()) + fetches.respond(RECEIVER_A) + assertEquals(listOf(receiver(RECEIVER_A)), withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }) + + repository.refreshReceiverList() + fetches.awaitStarted() + assertNull("재조회 로딩이 목록 방출을 만들었다", withTimeoutOrNull(QUIET_WAIT_MILLIS) { emissions.receive() }) + + fetches.fail(UnknownHostException("오프라인")) + assertEquals(listOf(receiver(RECEIVER_A)), withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }) + } finally { + collector.cancelAndJoin() + } + } + + /** 두 공개 Flow 가 같은 revision 을 본다. 재시도가 목록 전용 구독자도 다시 조회하게 하는 것은 의도된 공유다. */ + @Test + fun `refreshReceiverList - 목록 전용 구독자도 함께 다시 조회한다`() = + runBlocking { + val repository = repository() + val emissions = Channel>(capacity = Channel.UNLIMITED) + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { repository.receiverListFlow.collect { emissions.send(it) } } + + try { + fetches.awaitStarted() + fetches.respond(RECEIVER_A) + assertEquals(listOf(receiver(RECEIVER_A)), withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }) + + repository.refreshReceiverList() + + fetches.awaitStarted() + fetches.respond(RECEIVER_B) + assertEquals(listOf(receiver(RECEIVER_B)), withTimeout(TEST_TIMEOUT_MILLIS) { emissions.receive() }) + } finally { + collector.cancelAndJoin() + } + } + + private fun repository(): UserReceiverRepositoryImpl = + UserReceiverRepositoryImpl( + userApiService = ScriptedUserApiService(fetches), + tokenDataSource = sessionStore.tokenDataSource, + errorReporter = reporter, + ) + + private suspend fun loading(previous: List?): ReceiverListState.Loading = + ReceiverListState.Loading(previous, requireNotNull(sessionStore.tokenDataSource.sessionId.first())) + + private fun CoroutineScope.collectStates(repository: UserReceiverRepository): StateRecorder { + val channel = Channel(capacity = Channel.UNLIMITED) + val job = launch(start = CoroutineStart.UNDISPATCHED) { repository.receiverListStateFlow.collect { channel.send(it) } } + return StateRecorder(channel, job) + } + + private inner class StateRecorder( + private val channel: Channel, + private val job: Job, + ) { + suspend fun next(): ReceiverListState = withTimeout(TEST_TIMEOUT_MILLIS) { channel.receive() } + + suspend fun nextOrNull(): ReceiverListState? = withTimeoutOrNull(QUIET_WAIT_MILLIS) { channel.receive() } + + /** 첫 로딩과 첫 조회 성공까지 지나 보낸다. */ + suspend fun succeedFirst(name: String) { + assertEquals(loading(null), next()) + fetches.awaitStarted() + fetches.respond(name) + assertEquals(ReceiverListState.Success(listOf(receiver(name))), next()) + } + + suspend fun stop() = job.cancelAndJoin() + } + + /** `getReceivers` 를 테스트가 한 건씩 여는 조회. 시작 시점과 응답 시점을 따로 쥐어야 로딩 구간을 단언할 수 있다. */ + private class ScriptedFetches { + private val started = Channel(capacity = Channel.UNLIMITED) + private val responses = Channel>>(capacity = Channel.UNLIMITED) + private val startedCounter = AtomicInteger() + + val startedCount: Int get() = startedCounter.get() + + suspend fun fetch(): BaseResponse> { + startedCounter.incrementAndGet() + started.send(Unit) + val names = responses.receive().getOrThrow() + return BaseResponse( + status = 200, + code = 200, + data = names.map { ReceiverListDto(receiverId = receiverIdOf(it), name = it, relation = RELATION) }, + ) + } + + suspend fun awaitStarted() { + withTimeout(TEST_TIMEOUT_MILLIS) { started.receive() } + } + + suspend fun respond(vararg names: String) = responses.send(Result.success(names.toList())) + + suspend fun fail(error: Throwable) = responses.send(Result.failure(error)) + } + + private class RecordingErrorReporter : ErrorReporter { + val failures = CopyOnWriteArrayList() + + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + failures += throwable + } + } + + private class ScriptedUserApiService( + private val fetches: ScriptedFetches, + ) : UserApiService { + override suspend fun getReceivers(): BaseResponse> = fetches.fetch() + + override suspend fun createReceiver(request: UserCreateReceiverRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getReceiverDetail(receiverId: Long): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiver( + receiverId: Long, + request: UserPatchReceiverRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiverMessage( + receiverId: Long, + request: UserUpdateReceiverMessageRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getMyProfile(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyProfile(request: UserUpdateProfileRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun deleteAccount(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun registerPushToken(request: RegisterPushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun deletePushToken(request: DeletePushTokenRequestDto): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getMyPushSettings(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyPushSettings(request: UserUpdatePushSettingRequestDto): BaseResponse = + TODO("이 테스트 미사용") + + override suspend fun getMyMarketingConsents(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateMyMarketingConsents( + request: UserUpdateMarketingConsentRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getConnectedAccounts(): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun linkConnectedAccount( + provider: String, + request: SocialAccountLinkRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun unlinkConnectedAccount(provider: String): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun getReceiverDeliveryConditions(receiverId: Long): BaseResponse = TODO("이 테스트 미사용") + + override suspend fun updateReceiverDeliveryConditions( + receiverId: Long, + request: ReceiverDeliveryConditionUpdateRequestDto, + ): BaseResponse = TODO("이 테스트 미사용") + } + + private companion object { + const val RECEIVER_A = "계정 A 수신인" + const val RECEIVER_B = "계정 B 수신인" + const val RELATION = "친구" + const val TEST_TIMEOUT_MILLIS = 5_000L + const val QUIET_WAIT_MILLIS = 500L + + fun receiverIdOf(name: String): Long = if (name == RECEIVER_A) 1L else 2L + + fun receiver(name: String) = Receiver(receiverId = receiverIdOf(name), name = name, relation = RELATION) + } +} diff --git a/core/domain/src/main/java/com/afternote/core/domain/model/ReceiverListState.kt b/core/domain/src/main/java/com/afternote/core/domain/model/ReceiverListState.kt new file mode 100644 index 0000000000..bc308aa505 --- /dev/null +++ b/core/domain/src/main/java/com/afternote/core/domain/model/ReceiverListState.kt @@ -0,0 +1,37 @@ +package com.afternote.core.domain.model + +import com.afternote.core.model.user.Receiver + +/** + * 수신자 목록 조회의 결과 (#2045). + * + * `UserReceiverRepository.receiverListFlow` 는 실패를 마지막 목록이나 빈 목록으로 낮추므로 조회 중, 실패, + * 실제 0건이 같은 모양으로 들어온다. 이 타입은 그 셋을 가른다. + * + * 이전 목록([Loading.previousReceivers], [Failure.previousReceivers])은 같은 로그인 세션에서 이 구독이 + * 마지막으로 성공한 목록이다. 이 구독에서 아직 성공한 조회가 없거나 401 로 버렸으면 null 이다. + */ +sealed interface ReceiverListState { + data class Loading( + val previousReceivers: List?, + /** 로그인 구간의 불투명 식별자. 구독을 중단한 화면도 다른 계정의 행을 유지하지 않는다. */ + val sessionId: String, + ) : ReceiverListState + + data class Success( + val receivers: List, + ) : ReceiverListState + + /** 원래 오류는 데이터 계층이 ErrorReporter 로 남긴다. 구독 취소로 끊긴 조회는 이 값을 내지 않는다. */ + data class Failure( + val previousReceivers: List?, + /** 인증 실패로 폐기한 목록은 화면이 가지고 있던 행으로도 복원하지 않는다. */ + val discardPrevious: Boolean, + ) : ReceiverListState + + /** + * 로그인 세션이 없다. 구독 중에 세션이 바뀌어도 새 세션 조회 전에 이 값을 먼저 받는다. 중간 로그아웃 + * 방출이 저장소에서 합쳐져 사라진 경우라서다 (#2135). 받은 쪽은 이전 목록을 더 보여 주지 않는다. + */ + data object SignedOut : ReceiverListState +} diff --git a/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt b/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt index 5f096bdde6..82b2f26872 100644 --- a/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt +++ b/core/domain/src/main/java/com/afternote/core/domain/repository/UserReceiverRepository.kt @@ -1,5 +1,6 @@ package com.afternote.core.domain.repository +import com.afternote.core.domain.model.ReceiverListState import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.ReceiverDeliveryConditions import com.afternote.core.model.user.Receiver @@ -20,10 +21,28 @@ interface UserReceiverRepository { * * **빈 목록은 «수신인이 없음» 을 뜻하지 않는다 — 실패도 같은 모양으로 들어온다.** 화면이 이 값만 보고 * «등록된 수신인이 없어요» 를 확정하면 오프라인에서 그 거짓을 사용자에게 보여 주게 된다. 실패와 없음을 - * 가르려면 별도 신호가 필요하고, 그 표기는 #714 범위다. + * 가르려면 [receiverListStateFlow] 를 쓴다. + * + * 로그인한 구독의 첫 방출은 조회 결과다. 조회 중에는 아무것도 내지 않으므로 `first()` 로 받는 호출처가 + * 요청 전에 끊기지 않는다. */ val receiverListFlow: Flow> + /** + * [receiverListFlow] 와 같은 조회를 로딩·성공·실패로 갈라 낸다 (#2045). 두 Flow 는 한 원천에서 나오므로 + * 세션 격리, 늦은 결과 폐기, 갱신 revision 이 같다. + * + * 구독은 [ReceiverListState.Loading] 으로 시작하고, 같은 구독이 다시 조회할 때마다 Loading 을 먼저 낸 뒤 + * 결과를 낸다. 세션이 바뀌면 새 세션의 Loading 앞에 [ReceiverListState.SignedOut] 을 낸다. + */ + val receiverListStateFlow: Flow + + /** + * 구독 중인 수신자 목록을 모두 다시 조회하게 한다 (#2045). 결과는 두 목록 Flow 로 온다. 새 구독은 이 호출 + * 없이도 조회한다. + */ + fun refreshReceiverList() + // 수신자 목록 조회 suspend fun getReceivers(): List diff --git a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt index bf29f3eefc..7623295d53 100644 --- a/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt +++ b/core/domain/src/testFixtures/kotlin/com/afternote/core/domain/testing/FakeUserReceiverRepository.kt @@ -1,5 +1,6 @@ package com.afternote.core.domain.testing +import com.afternote.core.domain.model.ReceiverListState import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.model.delivery.DeliveryConditionItem import com.afternote.core.model.delivery.ReceiverDeliveryConditions @@ -8,6 +9,7 @@ import com.afternote.core.model.user.ReceiverCreated import com.afternote.core.model.user.ReceiverDetail import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicInteger @@ -33,6 +35,8 @@ class FakeUserReceiverRepository( var onUpdateReceiverMessage: (suspend (Long, String) -> Unit)? = null, var onGetReceiverDeliveryConditions: (suspend (Long) -> ReceiverDeliveryConditions)? = null, var onUpdateReceiverDeliveryConditions: (suspend (Long, List) -> ReceiverDeliveryConditions)? = null, + var onReceiverListStateFlow: (() -> Flow)? = null, + var onRefreshReceiverList: (() -> Unit)? = null, ) : UserReceiverRepository { val receiverState = MutableStateFlow(receivers.toList()) val receiverDetails = ConcurrentHashMap(receiverDetails) @@ -45,6 +49,7 @@ class FakeUserReceiverRepository( private val receiverListFlowCounter = AtomicInteger() private val getReceiversCounter = AtomicInteger() + private val refreshReceiverListCounter = AtomicInteger() val receiverCreateCalls = CopyOnWriteArrayList() val receiverDetailCalls = CopyOnWriteArrayList() @@ -56,6 +61,7 @@ class FakeUserReceiverRepository( val receiverListFlowCalls: Int get() = receiverListFlowCounter.get() val getReceiversCalls: Int get() = getReceiversCounter.get() val receiverCalls: Int get() = getReceiversCounter.get() + val refreshReceiverListCalls: Int get() = refreshReceiverListCounter.get() override val receiverListFlow: Flow> get() { @@ -63,6 +69,18 @@ class FakeUserReceiverRepository( return onReceiverListFlow?.invoke() ?: receiverState } + /** + * 기본값은 [receiverListFlow] 를 성공 결과로 감싼 것이다. 목록만 갈아끼운 기존 시나리오가 상태 소비자에도 + * 그대로 먹히고, strict 도 목록 Flow 쪽 거부를 따른다. 로딩·실패는 `onReceiverListStateFlow` 로 연다. + */ + override val receiverListStateFlow: Flow + get() = onReceiverListStateFlow?.invoke() ?: receiverListFlow.map(ReceiverListState::Success) + + override fun refreshReceiverList() { + refreshReceiverListCounter.incrementAndGet() + onRefreshReceiverList?.invoke() + } + override suspend fun getReceivers(): List { getReceiversCounter.incrementAndGet() onGetReceivers?.let { return it() } @@ -191,6 +209,7 @@ class FakeUserReceiverRepository( onUpdateReceiverDeliveryConditions = { _, _ -> unexpectedCall("UserReceiverRepository.updateReceiverDeliveryConditions") }, + onRefreshReceiverList = { unexpectedCall("UserReceiverRepository.refreshReceiverList") }, ) } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/component/ListRefreshErrorBanner.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/loading/ListRefreshErrorBanner.kt similarity index 76% rename from feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/component/ListRefreshErrorBanner.kt rename to core/ui/src/main/kotlin/com/afternote/core/ui/loading/ListRefreshErrorBanner.kt index 03508432f1..652126335b 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/shared/component/ListRefreshErrorBanner.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/loading/ListRefreshErrorBanner.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.afternote.presentation.shared.component +package com.afternote.core.ui.loading import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row @@ -13,25 +13,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.afternote.core.ui.R import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.feature.afternote.presentation.R /** * 목록을 이미 그려 둔 상태에서 새로고침이 실패했을 때 목록 위에 얹는 안내 (#705). * * Paging 은 refresh 가 실패해도 기존 페이지를 유지하므로, 화면이 말하지 않으면 «표시만 갱신되지 - * 않은» 무음 실패가 된다. 전면 오류([ErrorListBody])로 갈아끼우지 않는 이유는 이미 보고 있는 + * 않은» 무음 실패가 된다. 전면 오류(애프터노트 `ErrorListBody`)로 갈아끼우지 않는 이유는 이미 보고 있는 * 목록이 여전히 유효해서다 — 목록은 남기고 실패와 복구 수단만 덧붙인다. * * 스낵바가 아니라 상주 배너인 이유: 스낵바는 스스로 사라져 놓친 사용자에게 복구 수단이 남지 않는다. * 이 배너는 다음 새로고침이 성공할 때까지 «다시 시도» 를 화면에 붙들어 둔다. * + * 애프터노트 홈 목록과 설정 수신자 관리 목록이 같이 쓴다. 설정이 같은 구성의 사본을 만들던 것을 + * core 로 올렸다 (#2045). + * * 표시 방식 통일(#446) 결론이 나오면 본문 표현만 교체한다 — 호출부 배선은 유지. * - * @param onRetry «다시 시도» 클릭. 보통 [androidx.paging.compose.LazyPagingItems.retry] 를 전달한다. + * @param onRetry «다시 시도» 클릭. Paging 목록은 보통 `LazyPagingItems.retry` 를 전달한다. */ @Composable -fun ListRefreshErrorBanner( +public fun ListRefreshErrorBanner( onRetry: () -> Unit, modifier: Modifier = Modifier, ) { @@ -46,14 +49,14 @@ fun ListRefreshErrorBanner( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.afternote_home_refresh_error), + text = stringResource(R.string.core_ui_list_refresh_error), style = AfternoteDesign.typography.captionLargeR, color = AfternoteDesign.colors.gray7, modifier = Modifier.weight(1f), ) TextButton(onClick = onRetry) { Text( - text = stringResource(R.string.afternote_home_retry), + text = stringResource(R.string.core_ui_list_refresh_retry), style = AfternoteDesign.typography.captionLargeB, color = AfternoteDesign.colors.gray9, ) diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverListLoadFailure.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverListLoadFailure.kt new file mode 100644 index 0000000000..2093a002e9 --- /dev/null +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/receiver/ReceiverListLoadFailure.kt @@ -0,0 +1,48 @@ +package com.afternote.core.ui.receiver + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.R +import com.afternote.core.ui.button.AfternoteButton +import com.afternote.core.ui.theme.AfternoteDesign + +/** + * 수신자 목록 조회 실패 안내 (#2045). 보여 줄 행이 없을 때 목록 자리를 통째로 대체한다. + * + * 애프터노트 에디터의 수신자 선택과 설정 수신자 목록(관리·사후 전달 조건 선택)이 같은 문구·구성을 + * 각자 갖고 있던 것을 올렸다. [ReceiverSelectScreen] 의 `listReplacement` 슬롯에 그대로 끼운다. + * + * @param onRetryClick «다시 시도» 클릭. 목록을 다시 조회하는 일은 소비 기능이 맡는다. + */ +@Composable +public fun ReceiverListLoadFailure( + onRetryClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.core_ui_receiver_list_load_failed), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray8, + textAlign = TextAlign.Center, + ) + AfternoteButton( + text = stringResource(R.string.core_ui_receiver_list_retry), + onClick = onRetryClick, + modifier = Modifier.padding(top = 16.dp, start = 20.dp, end = 20.dp), + ) + } +} diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index f22ea01c5b..4bcc36bb95 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -62,6 +62,15 @@ 수신자 선택 이름으로 검색하기 수신자 선택 완료하기 + + 수신자 목록을 불러오지 못했습니다. + 다시 시도 + + 목록을 새로 불러오지 못했습니다. 지금 보이는 내용이 최신이 아닐 수 있어요. + 다시 시도 - 목록을 새로 불러오지 못했습니다. 지금 보이는 내용이 최신이 아닐 수 있어요. 다시 시도 이 카테고리에 등록된 애프터노트가 없어요.\n아래 연필 버튼을 눌러 애프터노트를 등록해 보세요. @@ -62,8 +59,6 @@ %1$s 선택하기 - 수신자 목록을 불러오지 못했습니다. - 다시 시도 등록된 수신자가 없습니다. 수신자를 등록하고 쉽게 관리해 보세요. 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 e400bef144..317aff6114 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 @@ -5,9 +5,9 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import com.afternote.core.ui.R +import com.afternote.core.ui.loading.ListRefreshErrorBanner import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.afternote.presentation.R -import com.afternote.feature.afternote.presentation.shared.component.ListRefreshErrorBanner import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -46,8 +46,8 @@ class AfternoteHomeRefreshErrorTest { AfternoteTheme { ListRefreshErrorBanner(onRetry = { retries += 1 }) } } - composeRule.onNodeWithText(string(R.string.afternote_home_refresh_error)).assertIsDisplayed() - composeRule.onNodeWithText(string(R.string.afternote_home_retry)).performClick() + composeRule.onNodeWithText(string(R.string.core_ui_list_refresh_error)).assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.core_ui_list_refresh_retry)).performClick() composeRule.runOnIdle { assertEquals(1, retries) } } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt index 8aa752b3a9..f30810c265 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/navigation/SettingNavHost.kt @@ -34,9 +34,8 @@ import com.afternote.feature.setting.presentation.profile.ProfileEditScreen import com.afternote.feature.setting.presentation.receiver.ReceiverEditScreen import com.afternote.feature.setting.presentation.receiver.ReceiverEditViewModel import com.afternote.feature.setting.presentation.receiver.ReceiverListIntent -import com.afternote.feature.setting.presentation.receiver.ReceiverListScreen +import com.afternote.feature.setting.presentation.receiver.ReceiverListRouteContent import com.afternote.feature.setting.presentation.receiver.ReceiverListViewModel -import com.afternote.feature.setting.presentation.receiver.ReceiverManageScreen import com.afternote.feature.setting.presentation.receiver.ReceiverRegisterScreen /** @@ -173,22 +172,15 @@ public fun SettingNavHost( viewModel.onIntent(ReceiverListIntent.ObservationStarted) onStopOrDispose { viewModel.onIntent(ReceiverListIntent.ObservationStopped) } } - if (key.selectForDeliveryConditions) { - ReceiverListScreen( - receivers = uiState.receivers, - onBackClick = actions::popBack, - onConfirmClick = { receiver -> - actions.onDeliveryConditionsRecipientSelected(receiver.receiverId) - }, - ) - } else { - ReceiverManageScreen( - receivers = uiState.receivers, - onBackClick = actions::popBack, - onReceiverClick = actions::onRecipientEditClick, - onRegisterClick = actions::onRecipientRegisterClick, - ) - } + ReceiverListRouteContent( + uiState = uiState, + selectForDeliveryConditions = key.selectForDeliveryConditions, + onBackClick = actions::popBack, + onRetryClick = { viewModel.onIntent(ReceiverListIntent.Retry) }, + onConfirmClick = { receiver -> actions.onDeliveryConditionsRecipientSelected(receiver.receiverId) }, + onReceiverClick = actions::onRecipientEditClick, + onRegisterClick = actions::onRecipientRegisterClick, + ) } entry { diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListContract.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListContract.kt index ddb68c44f3..5ae8615f29 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListContract.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListContract.kt @@ -1,5 +1,6 @@ package com.afternote.feature.setting.presentation.receiver +import com.afternote.core.domain.model.ReceiverListState import com.afternote.core.model.setting.ReceiverListItem import com.afternote.core.ui.mvi.MviIntent import com.afternote.core.ui.mvi.ReducerEvent @@ -7,16 +8,29 @@ import com.afternote.core.ui.mvi.UiState internal data class ReceiverListUiState( val receivers: List = emptyList(), + val loadState: ReceiverListLoadState = ReceiverListLoadState.Loading, + val sessionId: String? = null, ) : UiState internal sealed interface ReceiverListIntent : MviIntent { data object ObservationStarted : ReceiverListIntent data object ObservationStopped : ReceiverListIntent + + data object Retry : ReceiverListIntent } internal sealed interface ReceiverListReducerEvent : ReducerEvent { - data class ReceiversChanged( - val receivers: List, + data object RetryStarted : ReceiverListReducerEvent + + data class RepositoryStateChanged( + val state: ReceiverListState, ) : ReceiverListReducerEvent } + +internal enum class ReceiverListLoadState { + Loading, + Ready, + Failure, + RefreshFailure, +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListRouteContent.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListRouteContent.kt new file mode 100644 index 0000000000..a63d949d9a --- /dev/null +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListRouteContent.kt @@ -0,0 +1,62 @@ +package com.afternote.feature.setting.presentation.receiver + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.ui.loading.ListRefreshErrorBanner +import com.afternote.core.ui.loading.LoadingBody +import com.afternote.core.ui.receiver.ReceiverListLoadFailure + +/** + * 설정 수신자 목록 라우트의 조회 상태 분기 (#1281). 관리·선택 두 진입이 같은 규칙을 쓴다. + * + * 보여 줄 행이 없을 때만 목록 자리를 로딩이나 실패 안내로 바꾼다. 행이 있으면 조회 중에도, 다시 불러오기가 + * 실패해도 행과 검색·선택을 그대로 둔다. 0건 안내와 등록 버튼은 조회에 성공해 실제로 0건일 때만 나온다. + * + * 선택 진입은 공용 `ReceiverSelectScreen` 의 `listReplacement` 로만 넘긴다. 목록을 남긴 채 얹는 갱신 실패 + * 배너는 그 슬롯으로 표현할 수 없어 관리 진입에만 있다. + */ +@Composable +internal fun ReceiverListRouteContent( + uiState: ReceiverListUiState, + selectForDeliveryConditions: Boolean, + onBackClick: () -> Unit, + onRetryClick: () -> Unit, + onConfirmClick: (ReceiverListItem) -> Unit, + onReceiverClick: (Long) -> Unit, + onRegisterClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val listReplacement: (@Composable () -> Unit)? = + when { + uiState.receivers.isNotEmpty() -> null + uiState.loadState == ReceiverListLoadState.Loading -> ({ LoadingBody() }) + uiState.loadState == ReceiverListLoadState.Failure -> ({ ReceiverListLoadFailure(onRetryClick = onRetryClick) }) + else -> null + } + + if (selectForDeliveryConditions) { + ReceiverListScreen( + receivers = uiState.receivers, + onBackClick = onBackClick, + onConfirmClick = onConfirmClick, + modifier = modifier, + listReplacement = listReplacement, + ) + } else { + ReceiverManageScreen( + receivers = uiState.receivers, + onBackClick = onBackClick, + onReceiverClick = onReceiverClick, + onRegisterClick = onRegisterClick, + modifier = modifier, + listReplacement = listReplacement, + listHeader = + if (uiState.loadState == ReceiverListLoadState.RefreshFailure) { + { ListRefreshErrorBanner(onRetry = onRetryClick) } + } else { + null + }, + ) + } +} diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListScreen.kt index 2ddfaf0bee..19595dbb79 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListScreen.kt @@ -14,18 +14,20 @@ import com.afternote.core.ui.receiver.ReceiverSelectScreen * 설정의 "사후 전달 조건" 수신자 선택 화면 — 공용 [ReceiverSelectScreen] 소비 (#791). * * 검색·초성 인덱스·단일 선택·완료 UI 는 공용 화면이 그리고, 여기서는 - * 설정 모델 매핑과 화면 내 선택 상태만 소유한다. 공개 시그니처는 추출 전과 같다. + * 설정 모델 매핑과 화면 내 선택 상태만 소유한다. 추출 전 인자는 그대로 받는다. + * 조회 중·실패 표시는 호출부가 [listReplacement] 로 넘기고(#1281) 공용 화면의 같은 슬롯에 그대로 전달한다. * * 평범한 "수신자 목록" 열람·수정 진입은 #631 로 관리 화면([ReceiverManageScreen])이 맡고, * 이 화면은 사후 전달 조건 진입(`RecipientListRoute.selectForDeliveryConditions == true`) * 시의 선택 플로우에만 쓰인다. */ @Composable -fun ReceiverListScreen( +internal fun ReceiverListScreen( receivers: List, onBackClick: () -> Unit, onConfirmClick: (ReceiverListItem) -> Unit, modifier: Modifier = Modifier, + listReplacement: (@Composable () -> Unit)? = null, ) { var selectedId by remember { mutableStateOf(null) } @@ -46,5 +48,6 @@ fun ReceiverListScreen( receivers.find { it.receiverId == receiverId }?.let(onConfirmClick) }, modifier = modifier, + listReplacement = listReplacement, ) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListViewModel.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListViewModel.kt index 05fd70d063..7bc44417ca 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListViewModel.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListViewModel.kt @@ -1,8 +1,10 @@ package com.afternote.feature.setting.presentation.receiver import androidx.lifecycle.viewModelScope +import com.afternote.core.domain.model.ReceiverListState import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.model.user.Receiver import com.afternote.core.ui.mvi.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -23,6 +25,7 @@ internal class ReceiverListViewModel when (intent) { ReceiverListIntent.ObservationStarted -> startObservation() ReceiverListIntent.ObservationStopped -> stopObservation() + ReceiverListIntent.Retry -> retry() } } @@ -31,21 +34,32 @@ internal class ReceiverListViewModel event: ReceiverListReducerEvent, ): ReceiverListUiState = when (event) { - is ReceiverListReducerEvent.ReceiversChanged -> state.copy(receivers = event.receivers) + ReceiverListReducerEvent.RetryStarted -> state.copy(loadState = ReceiverListLoadState.Loading) + is ReceiverListReducerEvent.RepositoryStateChanged -> state.withRepositoryState(event.state) } + private fun retry() { + if (observation?.isActive != true) return + when (currentState.loadState) { + ReceiverListLoadState.Failure, ReceiverListLoadState.RefreshFailure -> { + dispatch(ReceiverListReducerEvent.RetryStarted) + receiverRepository.refreshReceiverList() + } + + ReceiverListLoadState.Loading, ReceiverListLoadState.Ready -> { + Unit + } + } + } + private fun startObservation() { pendingStop?.cancel() pendingStop = null if (observation?.isActive == true) return observation = viewModelScope.launch { - receiverRepository.receiverListFlow.collect { receivers -> - dispatch( - ReceiverListReducerEvent.ReceiversChanged( - receivers.map { ReceiverListItem(it.receiverId, it.name, it.relation) }, - ), - ) + receiverRepository.receiverListStateFlow.collect { state -> + dispatch(ReceiverListReducerEvent.RepositoryStateChanged(state)) } } } @@ -65,3 +79,39 @@ internal class ReceiverListViewModel const val OBSERVATION_STOP_TIMEOUT_MILLIS = 5_000L } } + +private fun ReceiverListUiState.withRepositoryState(state: ReceiverListState): ReceiverListUiState = + when (state) { + ReceiverListState.SignedOut -> { + ReceiverListUiState() + } + + is ReceiverListState.Loading -> { + copy( + receivers = state.previousReceivers?.toItems() ?: receivers.takeIf { sessionId == state.sessionId }.orEmpty(), + loadState = ReceiverListLoadState.Loading, + sessionId = state.sessionId, + ) + } + + is ReceiverListState.Success -> { + copy( + receivers = state.receivers.toItems(), + loadState = ReceiverListLoadState.Ready, + ) + } + + is ReceiverListState.Failure -> { + val remaining = + when { + state.discardPrevious -> emptyList() + else -> state.previousReceivers?.toItems() ?: receivers + } + copy( + receivers = remaining, + loadState = if (remaining.isEmpty()) ReceiverListLoadState.Failure else ReceiverListLoadState.RefreshFailure, + ) + } + } + +private fun List.toItems(): List = map { ReceiverListItem(it.receiverId, it.name, it.relation) } diff --git a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverManageScreen.kt b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverManageScreen.kt index db69c01829..1de6fa03df 100644 --- a/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverManageScreen.kt +++ b/feature/setting/presentation/src/main/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverManageScreen.kt @@ -38,14 +38,22 @@ import com.afternote.feature.setting.presentation.R * * 수신자가 0건이면 목록 대신 [ReceiverManageEmpty] 안내를 그린다 (#556). 이 화면엔 검색창이 * 없어서 검색 결과 0건 축은 여기 해당하지 않는다. 그 축은 검색어를 쥔 core:ui 안에서 닫는다. + * + * 0건이 조회 성공의 결과인지는 이 화면이 모른다. 조회 중·실패일 때는 호출부가 [listReplacement] 를 넘겨 + * 0건 안내 대신 그리게 한다 (#1281). + * + * @param listReplacement 목록 영역을 통째로 대체할 상태 화면. null 이면 목록이나 0건 안내를 그린다. + * @param listHeader 목록 위에 얹을 안내. 행은 그대로 둔다. */ @Composable -fun ReceiverManageScreen( +internal fun ReceiverManageScreen( receivers: List, onBackClick: () -> Unit, onReceiverClick: (Long) -> Unit, onRegisterClick: () -> Unit, modifier: Modifier = Modifier, + listReplacement: (@Composable () -> Unit)? = null, + listHeader: (@Composable () -> Unit)? = null, ) { Scaffold( modifier = modifier, @@ -57,28 +65,40 @@ fun ReceiverManageScreen( ) }, ) { innerPadding -> - if (receivers.isEmpty()) { - ReceiverManageEmpty( - onRegisterClick = onRegisterClick, - modifier = - Modifier - .fillMaxSize() - .padding(innerPadding), - ) - } else { - LazyColumn( - modifier = - Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = 20.dp), - ) { - items(receivers, key = { it.receiverId }) { receiver -> - ReceiverManageRow( - receiver = receiver, - onClick = { onReceiverClick(receiver.receiverId) }, + Column( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + listHeader?.invoke() + when { + listReplacement != null -> { + listReplacement() + } + + receivers.isEmpty() -> { + ReceiverManageEmpty( + onRegisterClick = onRegisterClick, + modifier = Modifier.fillMaxSize(), ) } + + else -> { + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + ) { + items(receivers, key = { it.receiverId }) { receiver -> + ReceiverManageRow( + receiver = receiver, + onClick = { onReceiverClick(receiver.receiverId) }, + ) + } + } + } } } } @@ -89,8 +109,9 @@ fun ReceiverManageScreen( * * 문구·일러스트 구성은 같은 성격의 확정 시안(4163:20979)을 그대로 구현한 애프터노트 * `SelectReceiverEmpty` 를 준거로 한다 (제목 → 8dp → 설명 → 56dp → 일러스트). - * 등록 CTA 는 Scaffold bottomBar 가 아니라 본문 안에 둔다. 뒤에 목록 상태 슬롯(#1281)으로 - * 이 본체를 통째로 옮길 때 빈 상태만 CTA 를 잃지 않게 하려는 것이다. + * 등록 CTA 는 Scaffold bottomBar 가 아니라 본문 안에 둔다. 목록 상태 슬롯(#1281)이 목록 영역을 + * 대체하는 동안 CTA 도 함께 사라지게 하려는 것이다. 조회 중·실패에 등록 버튼만 남으면 수신자가 없다고 + * 말하는 셈이 된다. */ @Composable private fun ReceiverManageEmpty( diff --git a/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTest.kt b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTest.kt new file mode 100644 index 0000000000..bfdbd74779 --- /dev/null +++ b/feature/setting/presentation/src/screenshotTest/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTest.kt @@ -0,0 +1,64 @@ +package com.afternote.feature.setting.presentation.receiver + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.ui.theme.AfternoteTheme +import com.android.tools.screenshot.PreviewTest + +/** 설정 수신자 목록의 조회 상태별 렌더 (#1281). 실제 0건 안내는 기존 관리 화면 그대로라 여기 두지 않는다. */ +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun receiverListInitialLoadingScreenshot() { + ReceiverListStateScreenshotContent(ReceiverListUiState(emptyList(), ReceiverListLoadState.Loading)) +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun receiverListLoadFailureScreenshot() { + ReceiverListStateScreenshotContent(ReceiverListUiState(emptyList(), ReceiverListLoadState.Failure)) +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun receiverListRefreshFailureScreenshot() { + ReceiverListStateScreenshotContent(ReceiverListUiState(previewReceivers, ReceiverListLoadState.RefreshFailure)) +} + +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun receiverListSelectLoadFailureScreenshot() { + ReceiverListStateScreenshotContent( + uiState = ReceiverListUiState(emptyList(), ReceiverListLoadState.Failure), + selectForDeliveryConditions = true, + ) +} + +@Composable +private fun ReceiverListStateScreenshotContent( + uiState: ReceiverListUiState, + selectForDeliveryConditions: Boolean = false, +) { + AfternoteTheme { + ReceiverListRouteContent( + uiState = uiState, + selectForDeliveryConditions = selectForDeliveryConditions, + onBackClick = {}, + onRetryClick = {}, + onConfirmClick = {}, + onReceiverClick = {}, + onRegisterClick = {}, + ) + } +} + +private val previewReceivers = + listOf( + ReceiverListItem(receiverId = 1L, name = "김지은", relation = "딸"), + ReceiverListItem(receiverId = 2L, name = "박경민", relation = "친구"), + ReceiverListItem(receiverId = 3L, name = "이서준", relation = "아들"), + ) diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListInitialLoadingScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListInitialLoadingScreenshot_748aa731_0.png new file mode 100644 index 0000000000..6e79a360b7 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListInitialLoadingScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListLoadFailureScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListLoadFailureScreenshot_748aa731_0.png new file mode 100644 index 0000000000..8edf26d127 Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListLoadFailureScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListRefreshFailureScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListRefreshFailureScreenshot_748aa731_0.png new file mode 100644 index 0000000000..938147ff0b Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListRefreshFailureScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListSelectLoadFailureScreenshot_748aa731_0.png b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListSelectLoadFailureScreenshot_748aa731_0.png new file mode 100644 index 0000000000..d2ba70a2bb Binary files /dev/null and b/feature/setting/presentation/src/screenshotTestDebug/reference/com/afternote/feature/setting/presentation/receiver/ReceiverListStateScreenshotTestKt/receiverListSelectLoadFailureScreenshot_748aa731_0.png differ diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverListRouteContentTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverListRouteContentTest.kt new file mode 100644 index 0000000000..b58c7b34c7 --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/ReceiverListRouteContentTest.kt @@ -0,0 +1,186 @@ +package com.afternote.feature.setting.presentation + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.afternote.core.model.setting.ReceiverListItem +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.receiver.ReceiverListLoadState +import com.afternote.feature.setting.presentation.receiver.ReceiverListRouteContent +import com.afternote.feature.setting.presentation.receiver.ReceiverListUiState +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * 설정 수신자 목록 라우트가 조회 상태를 관리·선택 두 화면에 옮기는 규칙 (#1281). + * + * 조회 중·실패를 성공한 0건으로 그리면 등록된 수신자가 없다는 거짓 안내와 등록 버튼이 뜬다. 그 두 줄이 + * 실제 0건일 때만 나오는지, 행이 있을 때는 조회 중·갱신 실패에도 행과 선택이 남는지를 본다. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class ReceiverListRouteContentTest { + @get:Rule + val composeRule = createComposeRule() + + private var retryCalls = 0 + private var confirmed: ReceiverListItem? = null + private var clickedReceiverId: Long? = null + + @Test + fun manage_firstLoading_showsProgressWithoutEmptyGuideOrRegisterButton() { + setContent(ReceiverListUiState(emptyList(), ReceiverListLoadState.Loading), selectForDeliveryConditions = false) + + composeRule.onAllNodes(progressMatcher).assertCountEquals(1) + composeRule.onNodeWithText(EMPTY_GUIDE).assertDoesNotExist() + composeRule.onNodeWithText(REGISTER).assertDoesNotExist() + } + + @Test + fun manage_failureWithoutRows_showsFailureAndRetryInsteadOfEmptyGuide() { + setContent(ReceiverListUiState(emptyList(), ReceiverListLoadState.Failure), selectForDeliveryConditions = false) + + composeRule.onNodeWithText(LOAD_FAILED).assertIsDisplayed() + composeRule.onNodeWithText(EMPTY_GUIDE).assertDoesNotExist() + composeRule.onNodeWithText(REGISTER).assertDoesNotExist() + composeRule.onNodeWithText(RETRY).performClick() + + assertEquals(1, retryCalls) + } + + @Test + fun manage_readyWithZeroReceivers_keepsEmptyGuideAndRegisterButton() { + setContent(ReceiverListUiState(emptyList(), ReceiverListLoadState.Ready), selectForDeliveryConditions = false) + + composeRule.onNodeWithText(EMPTY_GUIDE).assertIsDisplayed() + composeRule.onNodeWithText(REGISTER).assertIsDisplayed() + composeRule.onNodeWithText(LOAD_FAILED).assertDoesNotExist() + } + + @Test + fun manage_loadingWithRows_keepsRowsWithoutProgressOrBanner() { + setContent(ReceiverListUiState(receivers, ReceiverListLoadState.Loading), selectForDeliveryConditions = false) + + composeRule.onNodeWithText("박친구").assertIsDisplayed() + composeRule.onAllNodes(progressMatcher).assertCountEquals(0) + composeRule.onNodeWithText(REFRESH_FAILED).assertDoesNotExist() + } + + @Test + fun manage_refreshFailure_keepsRowsAndShowsBannerRetry() { + setContent(ReceiverListUiState(receivers, ReceiverListLoadState.RefreshFailure), selectForDeliveryConditions = false) + + composeRule.onNodeWithText(REFRESH_FAILED).assertIsDisplayed() + composeRule.onNodeWithText(LOAD_FAILED).assertDoesNotExist() + composeRule.onNodeWithText(RETRY).performClick() + composeRule.onNodeWithText("박친구").performClick() + + assertEquals(1, retryCalls) + assertEquals(11L, clickedReceiverId) + } + + @Test + fun select_firstLoading_replacesListWithProgress() { + setContent(ReceiverListUiState(emptyList(), ReceiverListLoadState.Loading), selectForDeliveryConditions = true) + + composeRule.onAllNodes(progressMatcher).assertCountEquals(1) + composeRule.onAllNodes(checkboxMatcher).assertCountEquals(0) + } + + @Test + fun select_failureWithoutRows_showsFailureAndRetry() { + setContent(ReceiverListUiState(emptyList(), ReceiverListLoadState.Failure), selectForDeliveryConditions = true) + + composeRule.onNodeWithText(LOAD_FAILED).assertIsDisplayed() + composeRule.onNodeWithText(RETRY).performClick() + + assertEquals(1, retryCalls) + } + + /** 검색·선택을 마친 뒤 다시 불러오기가 돌고 실패해도 같은 화면에서 그 선택으로 완료할 수 있어야 한다. */ + @Test + fun select_refreshLoadingThenFailure_keepsSearchAndSelection() { + var uiState by mutableStateOf(ReceiverListUiState(receivers, ReceiverListLoadState.Ready)) + composeRule.setContent { + AfternoteTheme { + ReceiverListRouteContent( + uiState = uiState, + selectForDeliveryConditions = true, + onBackClick = {}, + onRetryClick = { retryCalls += 1 }, + onConfirmClick = { confirmed = it }, + onReceiverClick = {}, + onRegisterClick = {}, + ) + } + } + composeRule.onNodeWithText("이름으로 검색하기").performTextInput("박") + composeRule.onAllNodes(checkboxMatcher).run { + assertCountEquals(1) + get(0).performClick() + } + + uiState = ReceiverListUiState(receivers, ReceiverListLoadState.Loading) + composeRule.waitForIdle() + uiState = ReceiverListUiState(receivers, ReceiverListLoadState.RefreshFailure) + composeRule.waitForIdle() + + composeRule.onNodeWithText("박친구").assertIsDisplayed() + composeRule.onNodeWithText("김수신").assertDoesNotExist() + composeRule.onNodeWithText("수신자 선택 완료하기").performClick() + assertEquals(receivers[1], confirmed) + } + + private fun setContent( + uiState: ReceiverListUiState, + selectForDeliveryConditions: Boolean, + ) { + composeRule.setContent { + AfternoteTheme { + ReceiverListRouteContent( + uiState = uiState, + selectForDeliveryConditions = selectForDeliveryConditions, + onBackClick = {}, + onRetryClick = { retryCalls += 1 }, + onConfirmClick = { confirmed = it }, + onReceiverClick = { clickedReceiverId = it }, + onRegisterClick = {}, + ) + } + } + } + + private val receivers = + listOf( + ReceiverListItem(receiverId = 7L, name = "김수신", relation = "가족"), + ReceiverListItem(receiverId = 11L, name = "박친구", relation = "친구"), + ) + + private companion object { + const val EMPTY_GUIDE = "등록된 수신자가 없습니다." + const val REGISTER = "수신자 등록하기" + const val LOAD_FAILED = "수신자 목록을 불러오지 못했습니다." + const val REFRESH_FAILED = "목록을 새로 불러오지 못했습니다. 지금 보이는 내용이 최신이 아닐 수 있어요." + const val RETRY = "다시 시도" + + val checkboxMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox) + val progressMatcher = + SemanticsMatcher.expectValue(SemanticsProperties.ProgressBarRangeInfo, ProgressBarRangeInfo.Indeterminate) + } +} diff --git a/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListViewModelTest.kt b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListViewModelTest.kt new file mode 100644 index 0000000000..89587eb0fa --- /dev/null +++ b/feature/setting/presentation/src/test/kotlin/com/afternote/feature/setting/presentation/receiver/ReceiverListViewModelTest.kt @@ -0,0 +1,214 @@ +package com.afternote.feature.setting.presentation.receiver + +import androidx.lifecycle.ViewModelStore +import com.afternote.core.domain.model.ReceiverListState +import com.afternote.core.domain.testing.FakeUserReceiverRepository +import com.afternote.core.model.user.Receiver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** 공개 Intent와 UiState를 통해 조회 결과, 재구독, 재시도와 세션 격리를 검증한다. */ +@OptIn(ExperimentalCoroutinesApi::class) +class ReceiverListViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + private val store = ViewModelStore() + private val states = MutableSharedFlow() + private val repository = FakeUserReceiverRepository(onReceiverListStateFlow = { states }) + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + store.clear() + Dispatchers.resetMain() + } + + @Test + fun `첫 결과 전에는 행 없는 로딩이고 성공한 0건만 Ready 다`() = + runTest(dispatcher) { + val vm = viewModel() + assertState(vm, ReceiverListLoadState.Loading) + states.emit(ReceiverListState.Loading(null, SESSION_A)) + assertState(vm, ReceiverListLoadState.Loading) + states.emit(ReceiverListState.Success(emptyList())) + assertState(vm, ReceiverListLoadState.Ready) + } + + @Test + fun `첫 실패 뒤 연속 Retry는 요청 하나만 보내고 즉시 로딩이 된다`() = + runTest(dispatcher) { + val vm = viewModel() + states.emit(ReceiverListState.Loading(null, SESSION_A)) + states.emit(ReceiverListState.Failure(null, discardPrevious = false)) + assertState(vm, ReceiverListLoadState.Failure) + vm.onIntent(ReceiverListIntent.Retry) + vm.onIntent(ReceiverListIntent.Retry) + assertEquals(1, repository.refreshReceiverListCalls) + assertState(vm, ReceiverListLoadState.Loading) + } + + @Test + fun `갱신 실패는 마지막 행을 보존하고 Retry 뒤에도 행을 유지한다`() = + runTest(dispatcher) { + val vm = viewModel() + succeed(KIM, PARK) + states.emit(ReceiverListState.Loading(listOf(KIM, PARK), SESSION_A)) + assertState(vm, ReceiverListLoadState.Loading, KIM, PARK) + states.emit(ReceiverListState.Failure(listOf(KIM, PARK), discardPrevious = false)) + assertState(vm, ReceiverListLoadState.RefreshFailure, KIM, PARK) + vm.onIntent(ReceiverListIntent.Retry) + assertEquals(1, repository.refreshReceiverListCalls) + assertState(vm, ReceiverListLoadState.Loading, KIM, PARK) + } + + @Test + fun `직전 성공이 0건이면 갱신 실패는 전면 실패다`() = + runTest(dispatcher) { + val vm = viewModel() + succeed() + states.emit(ReceiverListState.Failure(emptyList(), discardPrevious = false)) + assertState(vm, ReceiverListLoadState.Failure) + } + + @Test + fun `로딩과 성공에서는 Retry가 요청을 보내지 않는다`() = + runTest(dispatcher) { + val vm = viewModel() + vm.onIntent(ReceiverListIntent.Retry) + succeed(KIM) + vm.onIntent(ReceiverListIntent.Retry) + states.emit(ReceiverListState.Loading(listOf(KIM), SESSION_A)) + vm.onIntent(ReceiverListIntent.Retry) + assertEquals(0, repository.refreshReceiverListCalls) + } + + @Test + fun `5초 넘긴 재구독도 같은 세션이면 첫 로딩과 실패에서 행을 유지한다`() = + runTest(dispatcher) { + val vm = viewModel() + succeed(KIM, PARK) + vm.onIntent(ReceiverListIntent.ObservationStopped) + advanceTimeBy(5_001) + runCurrent() + assertEquals(0, states.subscriptionCount.value) + vm.onIntent(ReceiverListIntent.ObservationStarted) + states.emit(ReceiverListState.Loading(null, SESSION_A)) + assertState(vm, ReceiverListLoadState.Loading, KIM, PARK) + states.emit(ReceiverListState.Failure(null, discardPrevious = false)) + assertState(vm, ReceiverListLoadState.RefreshFailure, KIM, PARK) + states.emit(ReceiverListState.Success(listOf(KIM))) + assertState(vm, ReceiverListLoadState.Ready, KIM) + } + + @Test + fun `5초 넘게 중지한 사이 세션이 바뀌면 새 로딩부터 이전 계정 행을 버린다`() = + runTest(dispatcher) { + val vm = viewModel() + succeed(KIM) + vm.onIntent(ReceiverListIntent.ObservationStopped) + advanceTimeBy(5_001) + runCurrent() + vm.onIntent(ReceiverListIntent.ObservationStarted) + states.emit(ReceiverListState.Loading(null, SESSION_B)) + assertState(vm, ReceiverListLoadState.Loading) + states.emit(ReceiverListState.Failure(null, discardPrevious = false)) + assertState(vm, ReceiverListLoadState.Failure) + } + + @Test + fun `SignedOut 뒤 로딩과 실패는 이전 계정 행을 복원하지 않는다`() = + runTest(dispatcher) { + val vm = viewModel() + succeed(KIM) + states.emit(ReceiverListState.SignedOut) + assertState(vm, ReceiverListLoadState.Loading) + states.emit(ReceiverListState.Loading(null, SESSION_B)) + states.emit(ReceiverListState.Failure(null, discardPrevious = false)) + assertState(vm, ReceiverListLoadState.Failure) + } + + @Test + fun `동일 구독의 401은 이전 행을 버리고 이후 실패에서도 복원하지 않는다`() = + runTest(dispatcher) { + val vm = viewModel() + succeed(KIM) + states.emit(ReceiverListState.Loading(listOf(KIM), SESSION_A)) + states.emit(ReceiverListState.Failure(null, discardPrevious = true)) + assertState(vm, ReceiverListLoadState.Failure) + states.emit(ReceiverListState.Loading(null, SESSION_A)) + states.emit(ReceiverListState.Failure(null, discardPrevious = false)) + assertState(vm, ReceiverListLoadState.Failure) + } + + @Test + fun `재구독 첫 요청의 401도 보이던 행을 버린다`() = + runTest(dispatcher) { + val vm = viewModel() + succeed(KIM) + vm.onIntent(ReceiverListIntent.ObservationStopped) + advanceTimeBy(5_001) + runCurrent() + vm.onIntent(ReceiverListIntent.ObservationStarted) + states.emit(ReceiverListState.Loading(null, SESSION_A)) + states.emit(ReceiverListState.Failure(null, discardPrevious = true)) + assertState(vm, ReceiverListLoadState.Failure) + } + + @Test + fun `관측이 종료된 실패 화면의 Retry는 요청을 보내지 않는다`() = + runTest(dispatcher) { + val vm = viewModel() + states.emit(ReceiverListState.Loading(null, SESSION_A)) + states.emit(ReceiverListState.Failure(null, discardPrevious = false)) + vm.onIntent(ReceiverListIntent.ObservationStopped) + advanceTimeBy(5_001) + runCurrent() + vm.onIntent(ReceiverListIntent.Retry) + assertEquals(0, repository.refreshReceiverListCalls) + } + + private fun viewModel(): ReceiverListViewModel = + ReceiverListViewModel(repository).also { + store.put("receiver-list", it) + it.onIntent(ReceiverListIntent.ObservationStarted) + } + + private suspend fun succeed(vararg receivers: Receiver) { + states.emit(ReceiverListState.Loading(null, SESSION_A)) + states.emit(ReceiverListState.Success(receivers.toList())) + } + + private fun assertState( + vm: ReceiverListViewModel, + loadState: ReceiverListLoadState, + vararg receivers: Receiver, + ) { + assertEquals(loadState, vm.uiState.value.loadState) + assertEquals( + receivers.map { it.receiverId }, + vm.uiState.value.receivers + .map { it.receiverId }, + ) + } + + private companion object { + const val SESSION_A = "session-a" + const val SESSION_B = "session-b" + val KIM = Receiver(receiverId = 7L, name = "김수신", relation = "가족") + val PARK = Receiver(receiverId = 11L, name = "박친구", relation = "친구") + } +}