diff --git a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt index 547b47f3..19b1d509 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt @@ -179,6 +179,7 @@ import kr.co.vividnext.sodalive.user.signup.SignUpViewModel import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelHomeViewModel import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelApi import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository +import kr.co.vividnext.sodalive.v2.creator.channel.live.CreatorChannelLiveViewModel import kr.co.vividnext.sodalive.v2.main.MainV2ViewModel import kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModel import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomApi @@ -409,6 +410,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { viewModel { HomeCreatorRankingViewModel(get()) } viewModel { HomeRecommendationViewModel(get()) } viewModel { CreatorChannelHomeViewModel(get()) } + viewModel { CreatorChannelLiveViewModel(get()) } viewModel { PushNotificationListViewModel(get()) } viewModel { CharacterTabViewModel(get()) } viewModel { CharacterDetailViewModel(get()) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveViewModel.kt new file mode 100644 index 00000000..58b47571 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveViewModel.kt @@ -0,0 +1,165 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.live + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.orhanobut.logger.Logger +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.schedulers.Schedulers +import kr.co.vividnext.sodalive.base.BaseViewModel +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.v2.common.data.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository +import kr.co.vividnext.sodalive.v2.creator.channel.live.data.CreatorChannelLiveTabResponse + +class CreatorChannelLiveViewModel( + private val repository: CreatorChannelRepository +) : BaseViewModel() { + + private val _liveStateLiveData = MutableLiveData() + val liveStateLiveData: LiveData + get() = _liveStateLiveData + + private var creatorId: Long = 0L + private var selectedSort: ContentSort = ContentSort.LATEST + private var requestGeneration: Int = 0 + + fun loadLive(creatorId: Long) { + if (creatorId <= 0) return + if (this.creatorId == creatorId && _liveStateLiveData.value != null) return + + this.creatorId = creatorId + loadFirstPage(selectedSort) + } + + fun changeSort(sort: ContentSort) { + if (sort == selectedSort) return + if (creatorId <= 0) return + + selectedSort = sort + loadFirstPage(sort) + } + + fun retryLive() { + if (creatorId <= 0) return + + loadFirstPage(selectedSort) + } + + fun loadMore() { + val content = _liveStateLiveData.value as? CreatorChannelLiveUiState.Content ?: return + if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return + + val generation = requestGeneration + _liveStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null) + requestLive(page = content.page + 1, sort = content.selectedSort, generation = generation) { response -> + val data = response.data + val current = _liveStateLiveData.value as? CreatorChannelLiveUiState.Content ?: content + if (response.success && data != null) { + _liveStateLiveData.value = data.toContentState( + liveReplayContents = current.liveReplayContents + data.liveReplayContents, + isLoadingMore = false + ) + } else { + _liveStateLiveData.value = current.copy( + isLoadingMore = false, + paginationErrorMessage = response.message + ) + } + } + } + + private fun loadFirstPage(sort: ContentSort) { + val generation = ++requestGeneration + _liveStateLiveData.value = CreatorChannelLiveUiState.Loading + requestLive(page = FIRST_PAGE, sort = sort, generation = generation) { response -> + val data = response.data + if (response.success && data != null) { + _liveStateLiveData.value = if (data.currentLive == null && data.liveReplayContents.isEmpty()) { + CreatorChannelLiveUiState.Empty + } else { + data.toContentState(liveReplayContents = data.liveReplayContents) + } + } else { + _liveStateLiveData.value = CreatorChannelLiveUiState.Error(response.message) + } + } + } + + private fun requestLive( + page: Int, + sort: ContentSort, + generation: Int, + onSuccess: (ApiResponse) -> Unit + ) { + compositeDisposable.add( + repository.getLive( + creatorId = creatorId, + page = page, + size = DEFAULT_PAGE_SIZE, + sort = sort, + token = authToken() + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + if (generation == requestGeneration) { + onSuccess(it) + } + }, + { + if (generation != requestGeneration) return@subscribe + + it.message?.let { message -> Logger.e(message) } + val current = _liveStateLiveData.value as? CreatorChannelLiveUiState.Content + _liveStateLiveData.value = if (current != null && page > FIRST_PAGE) { + current.copy(isLoadingMore = false, paginationErrorMessage = it.message) + } else { + CreatorChannelLiveUiState.Error(it.message) + } + } + ) + ) + } + + private fun CreatorChannelLiveTabResponse.toContentState( + liveReplayContents: List, + isLoadingMore: Boolean = false + ) = CreatorChannelLiveUiState.Content( + liveReplayContentCount = liveReplayContentCount, + currentLive = currentLive, + liveReplayContents = liveReplayContents, + selectedSort = sort, + page = page, + size = size, + hasNext = hasNext, + isLoadingMore = isLoadingMore + ) + + private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}" + + companion object { + const val DEFAULT_PAGE_SIZE = 10 + private const val FIRST_PAGE = 0 + } +} + +sealed interface CreatorChannelLiveUiState { + data object Loading : CreatorChannelLiveUiState + data object Empty : CreatorChannelLiveUiState + data class Error(val message: String?) : CreatorChannelLiveUiState + data class Content( + val liveReplayContentCount: Int, + val currentLive: CreatorChannelLiveResponse?, + val liveReplayContents: List, + val selectedSort: ContentSort, + val page: Int, + val size: Int, + val hasNext: Boolean, + val isLoadingMore: Boolean = false, + val paginationErrorMessage: String? = null + ) : CreatorChannelLiveUiState +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLivePaginationTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLivePaginationTest.kt new file mode 100644 index 00000000..4080a7b8 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLivePaginationTest.kt @@ -0,0 +1,219 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.live + +import android.app.Application +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.test.core.app.ApplicationProvider +import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins +import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.SingleSubject +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.v2.common.data.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository +import kr.co.vividnext.sodalive.v2.creator.channel.live.data.CreatorChannelLiveTabResponse +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class CreatorChannelLivePaginationTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var repository: CreatorChannelRepository + private lateinit var viewModel: CreatorChannelLiveViewModel + + @Before + fun setUp() { + setImmediateRxSchedulers() + SharedPreferenceManager.resetForTest() + SharedPreferenceManager.init(context) + SharedPreferenceManager.token = "test-token" + repository = org.mockito.kotlin.mock() + viewModel = CreatorChannelLiveViewModel(repository) + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + SharedPreferenceManager.resetForTest() + } + + @Test + fun `hasNext가 true이면 다음 페이지는 마지막 성공 응답의 page plus 1로 요청하고 append한다`() { + stubGetLive( + page = 0, + response = Single.just(ApiResponse(true, liveResponse(page = 0, ids = listOf(1L), hasNext = true), null)) + ) + stubGetLive( + page = 1, + response = Single.just(ApiResponse(true, liveResponse(page = 1, ids = listOf(2L), hasNext = false), null)) + ) + viewModel.loadLive(100L) + + viewModel.loadMore() + + val state = viewModel.liveStateLiveData.requireValue() as CreatorChannelLiveUiState.Content + assertEquals(1, state.page) + assertEquals(listOf(1L, 2L), state.liveReplayContents.map { it.audioContentId }) + assertFalse(state.hasNext) + verifyGetLive(page = 1) + } + + @Test + fun `다음 페이지 로딩 중 중복 load-more 요청은 막는다`() { + val pending = SingleSubject.create>() + stubGetLive( + page = 0, + response = Single.just(ApiResponse(true, liveResponse(page = 0, ids = listOf(1L), hasNext = true), null)) + ) + stubGetLive(page = 1, response = pending) + viewModel.loadLive(100L) + + viewModel.loadMore() + viewModel.loadMore() + + val loadingState = viewModel.liveStateLiveData.requireValue() as CreatorChannelLiveUiState.Content + assertTrue(loadingState.isLoadingMore) + verifyGetLive(page = 1, times = 1) + pending.onSuccess(ApiResponse(true, liveResponse(page = 1, ids = listOf(2L), hasNext = false), null)) + val loadedState = viewModel.liveStateLiveData.requireValue() as CreatorChannelLiveUiState.Content + assertEquals(listOf(1L, 2L), loadedState.liveReplayContents.map { it.audioContentId }) + } + + @Test + fun `다음 페이지 실패는 기존 목록을 유지하고 pagination error를 표시한다`() { + stubGetLive( + page = 0, + response = Single.just(ApiResponse(true, liveResponse(page = 0, ids = listOf(1L), hasNext = true), null)) + ) + stubGetLive(page = 1, response = Single.just(ApiResponse(false, null, "failed"))) + viewModel.loadLive(100L) + + viewModel.loadMore() + + val state = viewModel.liveStateLiveData.requireValue() as CreatorChannelLiveUiState.Content + assertEquals(listOf(1L), state.liveReplayContents.map { it.audioContentId }) + assertFalse(state.isLoadingMore) + assertEquals("failed", state.paginationErrorMessage) + } + + @Test + fun `이전 load-more 응답은 이후 정렬 변경 목록에 append되지 않는다`() { + val nextPagePending = SingleSubject.create>() + stubGetLive( + page = 0, + response = Single.just(ApiResponse(true, liveResponse(page = 0, ids = listOf(1L), hasNext = true), null)) + ) + stubGetLive(page = 1, response = nextPagePending) + stubGetLive( + page = 0, + sort = ContentSort.POPULAR, + response = Single.just( + ApiResponse( + true, + liveResponse(page = 0, sort = ContentSort.POPULAR, ids = listOf(10L), hasNext = false), + null + ) + ) + ) + viewModel.loadLive(100L) + + viewModel.loadMore() + viewModel.changeSort(ContentSort.POPULAR) + nextPagePending.onSuccess(ApiResponse(true, liveResponse(page = 1, ids = listOf(2L), hasNext = false), null)) + + val state = viewModel.liveStateLiveData.requireValue() as CreatorChannelLiveUiState.Content + assertEquals(ContentSort.POPULAR, state.selectedSort) + assertEquals(listOf(10L), state.liveReplayContents.map { it.audioContentId }) + } + + private fun stubGetLive( + page: Int, + sort: ContentSort = ContentSort.LATEST, + response: Single> + ) { + whenever( + repository.getLive( + 100L, + page, + CreatorChannelLiveViewModel.DEFAULT_PAGE_SIZE, + sort, + "Bearer test-token" + ) + ).thenReturn(response) + } + + private fun verifyGetLive( + page: Int, + sort: ContentSort = ContentSort.LATEST, + times: Int? = null + ) { + val verification = times?.let { verify(repository, times(it)) } ?: verify(repository) + verification.getLive( + 100L, + page, + CreatorChannelLiveViewModel.DEFAULT_PAGE_SIZE, + sort, + "Bearer test-token" + ) + } + + private fun setImmediateRxSchedulers() { + val trampoline = { _: Scheduler -> Schedulers.trampoline() } + RxJavaPlugins.setIoSchedulerHandler(trampoline) + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() } + } + + private fun liveResponse( + page: Int, + sort: ContentSort = ContentSort.LATEST, + ids: List, + hasNext: Boolean + ) = CreatorChannelLiveTabResponse( + liveReplayContentCount = ids.size, + currentLive = null, + liveReplayContents = ids.map { audioContent(it) }, + sort = sort, + page = page, + size = CreatorChannelLiveViewModel.DEFAULT_PAGE_SIZE, + hasNext = hasNext + ) + + private fun audioContent(id: Long) = CreatorChannelAudioContentResponse( + audioContentId = id, + title = "다시듣기 $id", + duration = null, + imageUrl = null, + price = 0, + isPointAvailable = true, + isFirstContent = false, + seriesName = null, + isOriginalSeries = null + ) + + private fun LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { value = it } + observeForever(observer) + removeObserver(observer) + return value + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveViewModelTest.kt new file mode 100644 index 00000000..6d4eff81 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveViewModelTest.kt @@ -0,0 +1,319 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.live + +import android.app.Application +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.test.core.app.ApplicationProvider +import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins +import io.reactivex.rxjava3.core.Scheduler +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.SingleSubject +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.v2.common.data.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository +import kr.co.vividnext.sodalive.v2.creator.channel.live.data.CreatorChannelLiveTabResponse +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class CreatorChannelLiveViewModelTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var repository: CreatorChannelRepository + private lateinit var viewModel: CreatorChannelLiveViewModel + + @Before + fun setUp() { + setImmediateRxSchedulers() + SharedPreferenceManager.resetForTest() + SharedPreferenceManager.init(context) + SharedPreferenceManager.token = "test-token" + repository = org.mockito.kotlin.mock() + viewModel = CreatorChannelLiveViewModel(repository) + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + SharedPreferenceManager.resetForTest() + } + + @Test + fun `최초 로드는 page 0과 최신순으로 라이브 API를 호출하고 Content를 emit한다`() { + stubGetLive( + page = 0, + response = Single.just(ApiResponse(true, liveResponse(page = 0, sort = ContentSort.LATEST), null)) + ) + + viewModel.loadLive(100L) + + val state = viewModel.liveStateLiveData.requireValue() as CreatorChannelLiveUiState.Content + assertEquals(ContentSort.LATEST, state.selectedSort) + assertEquals(0, state.page) + assertEquals(listOf(1L), state.liveReplayContents.map { it.audioContentId }) + verifyGetLive(page = 0) + } + + @Test + fun `같은 정렬을 다시 선택하면 API를 재호출하지 않는다`() { + stubGetLive( + page = 0, + response = Single.just(ApiResponse(true, liveResponse(page = 0, sort = ContentSort.LATEST), null)) + ) + viewModel.loadLive(100L) + + viewModel.changeSort(ContentSort.LATEST) + + verifyGetLive(page = 0, times = 1) + } + + @Test + fun `정렬 변경은 목록과 page를 초기화하고 첫 페이지를 다시 로드한다`() { + stubGetLive( + page = 0, + response = Single.just( + ApiResponse(true, liveResponse(page = 0, sort = ContentSort.LATEST, ids = listOf(1L)), null) + ) + ) + stubGetLive( + page = 0, + sort = ContentSort.POPULAR, + response = Single.just( + ApiResponse(true, liveResponse(page = 0, sort = ContentSort.POPULAR, ids = listOf(10L)), null) + ) + ) + viewModel.loadLive(100L) + + viewModel.changeSort(ContentSort.POPULAR) + + val state = viewModel.liveStateLiveData.requireValue() as CreatorChannelLiveUiState.Content + assertEquals(ContentSort.POPULAR, state.selectedSort) + assertEquals(0, state.page) + assertEquals(listOf(10L), state.liveReplayContents.map { it.audioContentId }) + verifyGetLive(page = 0, sort = ContentSort.POPULAR) + } + + @Test + fun `라이브 API failure는 Error를 emit한다`() { + stubGetLive(page = 0, response = Single.just(ApiResponse(false, null, "failed"))) + + viewModel.loadLive(100L) + + assertTrue(viewModel.liveStateLiveData.requireValue() is CreatorChannelLiveUiState.Error) + } + + @Test + fun `Error 상태에서 retryLive를 호출하면 같은 creatorId 첫 페이지를 재시도한다`() { + whenever( + repository.getLive( + 100L, + 0, + CreatorChannelLiveViewModel.DEFAULT_PAGE_SIZE, + ContentSort.LATEST, + "Bearer test-token" + ) + ).thenReturn( + Single.just(ApiResponse(false, null, "failed")), + Single.just(ApiResponse(true, liveResponse(page = 0, sort = ContentSort.LATEST, ids = listOf(1L)), null)) + ) + viewModel.loadLive(100L) + + viewModel.retryLive() + + val state = viewModel.liveStateLiveData.requireValue() as CreatorChannelLiveUiState.Content + assertEquals(listOf(1L), state.liveReplayContents.map { it.audioContentId }) + verifyGetLive(page = 0, times = 2) + } + + @Test + fun `현재 라이브와 다시듣기 목록이 모두 없으면 Empty를 emit한다`() { + stubGetLive( + page = 0, + response = Single.just(ApiResponse(true, liveResponse(page = 0, currentLive = null, ids = emptyList()), null)) + ) + + viewModel.loadLive(100L) + + assertTrue(viewModel.liveStateLiveData.requireValue() is CreatorChannelLiveUiState.Empty) + } + + @Test + fun `creatorId가 0 이하이면 라이브 API를 호출하지 않는다`() { + viewModel.loadLive(0L) + + verify(repository, never()).getLive(any(), any(), any(), any(), any()) + } + + @Test + fun `최초 로드 전 정렬 변경은 기본 최신순 최초 로드 계약을 바꾸지 않는다`() { + stubGetLive( + page = 0, + response = Single.just(ApiResponse(true, liveResponse(page = 0, sort = ContentSort.LATEST), null)) + ) + + viewModel.changeSort(ContentSort.POPULAR) + viewModel.loadLive(100L) + + val state = viewModel.liveStateLiveData.requireValue() as CreatorChannelLiveUiState.Content + assertEquals(ContentSort.LATEST, state.selectedSort) + verifyGetLive(page = 0) + verify(repository, never()).getLive( + 100L, + 0, + CreatorChannelLiveViewModel.DEFAULT_PAGE_SIZE, + ContentSort.POPULAR, + "Bearer test-token" + ) + } + + @Test + fun `이전 첫 페이지 응답은 이후 정렬 변경 결과를 덮어쓰지 않는다`() { + val latestPending = SingleSubject.create>() + val popularPending = SingleSubject.create>() + stubGetLive(page = 0, response = latestPending) + stubGetLive(page = 0, sort = ContentSort.POPULAR, response = popularPending) + + viewModel.loadLive(100L) + viewModel.changeSort(ContentSort.POPULAR) + popularPending.onSuccess(ApiResponse(true, liveResponse(page = 0, sort = ContentSort.POPULAR, ids = listOf(10L)), null)) + latestPending.onSuccess(ApiResponse(true, liveResponse(page = 0, sort = ContentSort.LATEST, ids = listOf(1L)), null)) + + val state = viewModel.liveStateLiveData.requireValue() as CreatorChannelLiveUiState.Content + assertEquals(ContentSort.POPULAR, state.selectedSort) + assertEquals(listOf(10L), state.liveReplayContents.map { it.audioContentId }) + } + + @Test + fun `같은 creatorId로 다시 loadLive를 호출하면 기존 상태를 유지하고 API를 재호출하지 않는다`() { + stubGetLive( + page = 0, + response = Single.just( + ApiResponse( + true, + liveResponse(page = 0, sort = ContentSort.LATEST, ids = listOf(1L), hasNext = true), + null + ) + ) + ) + stubGetLive( + page = 1, + response = Single.just( + ApiResponse( + true, + liveResponse(page = 1, sort = ContentSort.LATEST, ids = listOf(2L), hasNext = false), + null + ) + ) + ) + viewModel.loadLive(100L) + viewModel.loadMore() + + viewModel.loadLive(100L) + + val state = viewModel.liveStateLiveData.requireValue() as CreatorChannelLiveUiState.Content + assertEquals(1, state.page) + assertEquals(listOf(1L, 2L), state.liveReplayContents.map { it.audioContentId }) + verifyGetLive(page = 0, times = 1) + verifyGetLive(page = 1, times = 1) + } + + private fun stubGetLive( + page: Int, + sort: ContentSort = ContentSort.LATEST, + response: Single> + ) { + whenever( + repository.getLive( + 100L, + page, + CreatorChannelLiveViewModel.DEFAULT_PAGE_SIZE, + sort, + "Bearer test-token" + ) + ).thenReturn(response) + } + + private fun verifyGetLive( + page: Int, + sort: ContentSort = ContentSort.LATEST, + times: Int? = null + ) { + val verification = times?.let { verify(repository, times(it)) } ?: verify(repository) + verification.getLive( + 100L, + page, + CreatorChannelLiveViewModel.DEFAULT_PAGE_SIZE, + sort, + "Bearer test-token" + ) + } + + private fun setImmediateRxSchedulers() { + val trampoline = { _: Scheduler -> Schedulers.trampoline() } + RxJavaPlugins.setIoSchedulerHandler(trampoline) + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() } + } + + private fun liveResponse( + page: Int, + sort: ContentSort = ContentSort.LATEST, + currentLive: CreatorChannelLiveResponse? = CreatorChannelLiveResponse( + 1L, + "라이브", + null, + "2026-06-11T12:00:00Z", + 0, + false + ), + ids: List = listOf(1L), + hasNext: Boolean = false + ) = CreatorChannelLiveTabResponse( + liveReplayContentCount = ids.size, + currentLive = currentLive, + liveReplayContents = ids.map { audioContent(it) }, + sort = sort, + page = page, + size = CreatorChannelLiveViewModel.DEFAULT_PAGE_SIZE, + hasNext = hasNext + ) + + private fun audioContent(id: Long) = CreatorChannelAudioContentResponse( + audioContentId = id, + title = "다시듣기 $id", + duration = null, + imageUrl = null, + price = 0, + isPointAvailable = true, + isFirstContent = false, + seriesName = null, + isOriginalSeries = null + ) + + private fun LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { value = it } + observeForever(observer) + removeObserver(observer) + return value + } +}