From 0a97194b3140aecef8460729fc98cf4a4963a932 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 25 Jun 2026 11:53:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(content):=20=EC=A0=84=EC=B2=B4=20=ED=83=AD?= =?UTF-8?q?=20ViewModel=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/co/vividnext/sodalive/di/AppDI.kt | 2 + .../v2/main/content/ContentAllTabViewModel.kt | 235 +++++++++ .../content/model/MainContentAllTabUiState.kt | 39 +- .../content/ContentAllTabViewModelTest.kt | 487 ++++++++++++++++++ 4 files changed, 756 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModelTest.kt 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 1a689726..c4cacc2f 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 @@ -195,6 +195,7 @@ import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModel import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatApi import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketClient +import kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModel import kr.co.vividnext.sodalive.v2.main.content.ContentMainViewModel import kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModel import kr.co.vividnext.sodalive.v2.main.content.data.AudioRecommendationsApi @@ -426,6 +427,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { viewModel { HomeViewModel(get(), get()) } viewModel { ChatMainViewModel(get()) } viewModel { DmChatRoomViewModel(get()) } + viewModel { ContentAllTabViewModel(get()) } viewModel { ContentMainViewModel(get()) } viewModel { ContentRankingViewModel(get()) } viewModel { HomeCreatorRankingViewModel(get()) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt new file mode 100644 index 00000000..36c18638 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModel.kt @@ -0,0 +1,235 @@ +package kr.co.vividnext.sodalive.v2.main.content + +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.R +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.common.ToastMessage +import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.v2.common.data.ContentSort +import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllTabRepository +import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllTabResponse +import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType +import kr.co.vividnext.sodalive.v2.main.content.model.MainContentAllTabUiState +import kr.co.vividnext.sodalive.v2.main.content.model.currentDeviceDayOfWeek +import kr.co.vividnext.sodalive.v2.main.content.model.toContent +import kr.co.vividnext.sodalive.v2.main.content.model.toUiModel +import kr.co.vividnext.sodalive.v2.main.content.model.usesDayOfWeekQuery +import kr.co.vividnext.sodalive.v2.main.content.model.usesSeriesItems + +class ContentAllTabViewModel( + private val repository: MainContentAllTabRepository, + private val currentDayOfWeekProvider: () -> SeriesPublishedDaysOfWeek = { currentDeviceDayOfWeek() } +) : BaseViewModel() { + + private val _allTabStateLiveData = MutableLiveData() + val allTabStateLiveData: LiveData + get() = _allTabStateLiveData + + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData + get() = _isLoading + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + + private var selectedType: MainContentAllType = MainContentAllType.AUDIO + private var selectedSort: ContentSort = ContentSort.LATEST + private var selectedDayOfWeek: SeriesPublishedDaysOfWeek? = null + private var requestGeneration: Int = 0 + + fun loadContents() { + loadFirstPage(selectedType, selectedSort, selectedDayOfWeekFor(selectedType)) + } + + fun loadInitial() { + loadContents() + } + + fun changeType(type: MainContentAllType) { + selectedType = type + selectedDayOfWeek = selectedDayOfWeekFor(type) + loadFirstPage(type, selectedSort, selectedDayOfWeek) + } + + fun changeSort(sort: ContentSort) { + selectedSort = sort + loadFirstPage(selectedType, sort, selectedDayOfWeekFor(selectedType)) + } + + fun changeDayOfWeek(dayOfWeek: SeriesPublishedDaysOfWeek) { + if (!selectedType.usesDayOfWeekQuery()) return + + selectedDayOfWeek = dayOfWeek + loadFirstPage(selectedType, selectedSort, dayOfWeek) + } + + fun loadMore() { + val content = _allTabStateLiveData.value as? MainContentAllTabUiState.Content ?: return + if (!content.hasNext || content.isLoadingMore) return + + val generation = requestGeneration + _allTabStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null) + requestContents( + type = content.selectedType, + sort = content.selectedSort, + page = content.page + 1, + dayOfWeek = content.selectedDayOfWeek, + generation = generation + ) { response -> + val current = _allTabStateLiveData.value as? MainContentAllTabUiState.Content ?: content + val data = response.data + if (response.success && data != null) { + _allTabStateLiveData.value = current.append(data) + } else { + _allTabStateLiveData.value = current.copy( + isLoadingMore = false, + paginationErrorMessage = response.message + ) + } + } + } + + fun retry() { + loadContents() + } + + fun consumePaginationErrorMessage() { + val content = _allTabStateLiveData.value as? MainContentAllTabUiState.Content ?: return + if (content.paginationErrorMessage == null) return + + _allTabStateLiveData.value = content.copy(paginationErrorMessage = null) + } + + private fun loadFirstPage( + type: MainContentAllType, + sort: ContentSort, + dayOfWeek: SeriesPublishedDaysOfWeek? + ) { + val generation = ++requestGeneration + _isLoading.value = true + _allTabStateLiveData.value = MainContentAllTabUiState.Loading(type, sort, dayOfWeek, totalCount = 0) + requestContents(type, sort, FIRST_PAGE, dayOfWeek, generation) { response -> + _isLoading.value = false + val data = response.data + if (response.success && data != null) { + val content = data.toContent() + _allTabStateLiveData.value = if (content.isSelectedTypeEmpty()) { + MainContentAllTabUiState.Empty( + content.selectedType, + content.selectedSort, + content.selectedDayOfWeek, + content.totalCount + ) + } else { + content + } + } else { + showFirstPageError(type, sort, dayOfWeek, response.message) + } + } + } + + private fun requestContents( + type: MainContentAllType, + sort: ContentSort, + page: Int, + dayOfWeek: SeriesPublishedDaysOfWeek?, + generation: Int, + onSuccess: (ApiResponse) -> Unit + ) { + compositeDisposable.add( + repository.getContents(authToken(), type, sort, page, DEFAULT_PAGE_SIZE, dayOfWeek) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + if (generation == requestGeneration) { + onSuccess(it) + } + }, + { + if (generation != requestGeneration) return@subscribe + + it.message?.let { message -> Logger.e(message) } + _isLoading.value = false + val current = _allTabStateLiveData.value as? MainContentAllTabUiState.Content + if (current != null && page > FIRST_PAGE) { + _allTabStateLiveData.value = current.copy( + isLoadingMore = false, + paginationErrorMessage = it.message + ) + } else { + showFirstPageError(type, sort, dayOfWeek, it.message) + } + } + ) + ) + } + + private fun selectedDayOfWeekFor(type: MainContentAllType): SeriesPublishedDaysOfWeek? { + return if (type.usesDayOfWeekQuery()) selectedDayOfWeek ?: currentDayOfWeekProvider() else null + } + + private fun MainContentAllTabUiState.Content.isSelectedTypeEmpty(): Boolean { + return if (selectedType.usesSeriesItems()) seriesItems.isEmpty() else audioItems.isEmpty() + } + + private fun MainContentAllTabUiState.Content.append( + data: MainContentAllTabResponse + ): MainContentAllTabUiState.Content { + val mapped = data.toContent() + return copy( + selectedType = mapped.selectedType, + selectedSort = mapped.selectedSort, + selectedDayOfWeek = mapped.selectedDayOfWeek, + totalCount = mapped.totalCount, + audioItems = if (mapped.selectedType.usesSeriesItems()) { + emptyList() + } else { + audioItems + data.audios.map { it.toUiModel() } + }, + seriesItems = if (mapped.selectedType.usesSeriesItems()) { + seriesItems + data.series.map { + it.toUiModel(mapped.selectedType) + } + } else { + emptyList() + }, + page = mapped.page, + size = mapped.size, + hasNext = mapped.hasNext, + isLoadingMore = false, + paginationErrorMessage = null + ) + } + + private fun showFirstPageError( + type: MainContentAllType, + sort: ContentSort, + dayOfWeek: SeriesPublishedDaysOfWeek?, + message: String? + ) { + _allTabStateLiveData.value = MainContentAllTabUiState.Error( + selectedType = type, + selectedSort = sort, + selectedDayOfWeek = dayOfWeek, + totalCount = 0, + message = message + ) + _toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown) + } + + private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}" + + companion object { + const val DEFAULT_PAGE_SIZE = 20 + private const val FIRST_PAGE = 0 + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/MainContentAllTabUiState.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/MainContentAllTabUiState.kt index 61333ce7..29450d27 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/MainContentAllTabUiState.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/MainContentAllTabUiState.kt @@ -5,13 +5,25 @@ import kr.co.vividnext.sodalive.v2.common.data.ContentSort import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType sealed interface MainContentAllTabUiState { - data object Loading : MainContentAllTabUiState + val selectedType: MainContentAllType + val selectedSort: ContentSort + val selectedDayOfWeek: SeriesPublishedDaysOfWeek? + val totalCount: Int + + data class Loading( + override val selectedType: MainContentAllType, + override val selectedSort: ContentSort, + override val selectedDayOfWeek: SeriesPublishedDaysOfWeek?, + override val totalCount: Int + ) : MainContentAllTabUiState { + companion object + } data class Content( - val selectedType: MainContentAllType, - val selectedSort: ContentSort, - val selectedDayOfWeek: SeriesPublishedDaysOfWeek?, - val totalCount: Int, + override val selectedType: MainContentAllType, + override val selectedSort: ContentSort, + override val selectedDayOfWeek: SeriesPublishedDaysOfWeek?, + override val totalCount: Int, val audioItems: List, val seriesItems: List, val page: Int, @@ -21,7 +33,20 @@ sealed interface MainContentAllTabUiState { val paginationErrorMessage: String? = null ) : MainContentAllTabUiState - data object Empty : MainContentAllTabUiState + data class Empty( + override val selectedType: MainContentAllType, + override val selectedSort: ContentSort, + override val selectedDayOfWeek: SeriesPublishedDaysOfWeek?, + override val totalCount: Int + ) : MainContentAllTabUiState { + companion object + } - data class Error(val message: String? = null) : MainContentAllTabUiState + data class Error( + override val selectedType: MainContentAllType, + override val selectedSort: ContentSort, + override val selectedDayOfWeek: SeriesPublishedDaysOfWeek?, + override val totalCount: Int, + val message: String? = null + ) : MainContentAllTabUiState } diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModelTest.kt new file mode 100644 index 00000000..bde73aeb --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentAllTabViewModelTest.kt @@ -0,0 +1,487 @@ +package kr.co.vividnext.sodalive.v2.main.content + +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.home.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.v2.common.data.ContentSort +import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllTabRepository +import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllTabResponse +import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType +import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAudioResponse +import kr.co.vividnext.sodalive.v2.main.content.data.MainContentSeriesResponse +import kr.co.vividnext.sodalive.v2.main.content.model.MainContentAllTabUiState +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 ContentAllTabViewModelTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var repository: MainContentAllTabRepository + private lateinit var viewModel: ContentAllTabViewModel + + @Before + fun setUp() { + setImmediateRxSchedulers() + SharedPreferenceManager.resetForTest() + SharedPreferenceManager.init(context) + SharedPreferenceManager.token = "test-token" + repository = org.mockito.kotlin.mock() + viewModel = ContentAllTabViewModel( + repository = repository, + currentDayOfWeekProvider = { SeriesPublishedDaysOfWeek.WED } + ) + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + SharedPreferenceManager.resetForTest() + } + + @Test + fun `초기 로드는 AUDIO LATEST 첫 페이지를 dayOfWeek 없이 요청한다`() { + stubGetContents( + type = MainContentAllType.AUDIO, + response = Single.just(ApiResponse(true, response(audios = listOf(audio(1L))), null)) + ) + + viewModel.loadContents() + + val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content + assertEquals(MainContentAllType.AUDIO, state.selectedType) + assertEquals(ContentSort.LATEST, state.selectedSort) + assertEquals(null, state.selectedDayOfWeek) + assertEquals(listOf(1L), state.audioItems.map { it.audioContentId }) + verifyGetContents(type = MainContentAllType.AUDIO, page = 0, dayOfWeek = null) + } + + @Test + fun `첫 페이지 표시 목록이 비어있으면 totalCount가 있어도 Empty 상태로 표시한다`() { + stubGetContents( + type = MainContentAllType.AUDIO, + response = Single.just( + ApiResponse( + true, + response( + type = MainContentAllType.AUDIO, + totalCount = 3, + audios = emptyList() + ), + null + ) + ) + ) + + viewModel.loadContents() + + val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Empty + assertEquals(MainContentAllType.AUDIO, state.selectedType) + assertEquals(ContentSort.LATEST, state.selectedSort) + assertEquals(null, state.selectedDayOfWeek) + assertEquals(3, state.totalCount) + } + + @Test + fun `SERIES 변경 Loading은 선택 메타데이터를 유지한다`() { + val pending = SingleSubject.create>() + stubGetContents( + type = MainContentAllType.SERIES, + dayOfWeek = SeriesPublishedDaysOfWeek.WED, + response = pending + ) + + viewModel.changeType(MainContentAllType.SERIES) + + val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Loading + assertEquals(MainContentAllType.SERIES, state.selectedType) + assertEquals(ContentSort.LATEST, state.selectedSort) + assertEquals(SeriesPublishedDaysOfWeek.WED, state.selectedDayOfWeek) + assertEquals(0, state.totalCount) + } + + @Test + fun `SERIES 첫 페이지 표시 목록이 비어있으면 Empty가 선택 메타데이터와 totalCount를 유지한다`() { + stubGetContents( + type = MainContentAllType.SERIES, + dayOfWeek = SeriesPublishedDaysOfWeek.WED, + response = Single.just( + ApiResponse( + true, + response( + type = MainContentAllType.SERIES, + totalCount = 7, + dayOfWeek = SeriesPublishedDaysOfWeek.WED, + series = emptyList() + ), + null + ) + ) + ) + + viewModel.changeType(MainContentAllType.SERIES) + + val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Empty + assertEquals(MainContentAllType.SERIES, state.selectedType) + assertEquals(ContentSort.LATEST, state.selectedSort) + assertEquals(SeriesPublishedDaysOfWeek.WED, state.selectedDayOfWeek) + assertEquals(7, state.totalCount) + } + + @Test + fun `SERIES 첫 페이지 실패는 Error가 선택 메타데이터와 메시지를 유지한다`() { + stubGetContents( + type = MainContentAllType.SERIES, + dayOfWeek = SeriesPublishedDaysOfWeek.WED, + response = Single.just(ApiResponse(false, null, "failed")) + ) + + viewModel.changeType(MainContentAllType.SERIES) + + val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Error + assertEquals(MainContentAllType.SERIES, state.selectedType) + assertEquals(ContentSort.LATEST, state.selectedSort) + assertEquals(SeriesPublishedDaysOfWeek.WED, state.selectedDayOfWeek) + assertEquals(0, state.totalCount) + assertEquals("failed", state.message) + } + + @Test + fun `SERIES 선택은 현재 디바이스 요일로 첫 페이지를 요청한다`() { + stubGetContents( + type = MainContentAllType.SERIES, + dayOfWeek = SeriesPublishedDaysOfWeek.WED, + response = Single.just( + ApiResponse( + true, + response( + type = MainContentAllType.SERIES, + dayOfWeek = SeriesPublishedDaysOfWeek.WED, + series = listOf(series(10L)) + ), + null + ) + ) + ) + stubGetContents(response = Single.just(ApiResponse(true, response(), null))) + viewModel.loadContents() + + viewModel.changeType(MainContentAllType.SERIES) + + val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content + assertEquals(MainContentAllType.SERIES, state.selectedType) + assertEquals(SeriesPublishedDaysOfWeek.WED, state.selectedDayOfWeek) + assertEquals(listOf(10L), state.seriesItems.map { it.seriesId }) + verifyGetContents( + type = MainContentAllType.SERIES, + page = 0, + dayOfWeek = SeriesPublishedDaysOfWeek.WED + ) + } + + @Test + fun `SERIES가 아닌 type은 dayOfWeek 없이 요청한다`() { + listOf( + MainContentAllType.AUDIO, + MainContentAllType.FREE, + MainContentAllType.POINT, + MainContentAllType.ORIGINAL + ).forEach { type -> + stubGetContents( + type = type, + response = Single.just(ApiResponse(true, response(type = type), null)) + ) + } + + viewModel.changeType(MainContentAllType.AUDIO) + viewModel.changeType(MainContentAllType.FREE) + viewModel.changeType(MainContentAllType.POINT) + viewModel.changeType(MainContentAllType.ORIGINAL) + + verifyGetContents(type = MainContentAllType.AUDIO, page = 0, dayOfWeek = null) + verifyGetContents(type = MainContentAllType.FREE, page = 0, dayOfWeek = null) + verifyGetContents(type = MainContentAllType.POINT, page = 0, dayOfWeek = null) + verifyGetContents(type = MainContentAllType.ORIGINAL, page = 0, dayOfWeek = null) + } + + @Test + fun `SERIES에서 요일 변경은 변경 요일과 page 0으로 요청한다`() { + stubGetContents(response = Single.just(ApiResponse(true, response(), null))) + stubGetContents( + type = MainContentAllType.SERIES, + dayOfWeek = SeriesPublishedDaysOfWeek.WED, + response = Single.just( + ApiResponse( + true, + response( + type = MainContentAllType.SERIES, + dayOfWeek = SeriesPublishedDaysOfWeek.WED, + series = listOf(series(20L)) + ), + null + ) + ) + ) + stubGetContents( + type = MainContentAllType.SERIES, + dayOfWeek = SeriesPublishedDaysOfWeek.FRI, + response = Single.just( + ApiResponse( + true, + response( + type = MainContentAllType.SERIES, + dayOfWeek = SeriesPublishedDaysOfWeek.FRI, + series = listOf(series(21L)) + ), + null + ) + ) + ) + viewModel.loadContents() + viewModel.changeType(MainContentAllType.SERIES) + + viewModel.changeDayOfWeek(SeriesPublishedDaysOfWeek.FRI) + + val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content + assertEquals(SeriesPublishedDaysOfWeek.FRI, state.selectedDayOfWeek) + assertEquals(0, state.page) + verifyGetContents( + type = MainContentAllType.SERIES, + page = 0, + dayOfWeek = SeriesPublishedDaysOfWeek.FRI + ) + } + + @Test + fun `정렬 변경은 현재 type과 dayOfWeek를 유지하고 page 0으로 요청한다`() { + stubGetContents(response = Single.just(ApiResponse(true, response(), null))) + stubGetContents( + type = MainContentAllType.SERIES, + dayOfWeek = SeriesPublishedDaysOfWeek.WED, + response = Single.just( + ApiResponse( + true, + response( + type = MainContentAllType.SERIES, + dayOfWeek = SeriesPublishedDaysOfWeek.WED, + series = listOf(series(20L)) + ), + null + ) + ) + ) + stubGetContents( + type = MainContentAllType.SERIES, + sort = ContentSort.POPULAR, + dayOfWeek = SeriesPublishedDaysOfWeek.WED, + response = Single.just( + ApiResponse( + true, + response( + type = MainContentAllType.SERIES, + sort = ContentSort.POPULAR, + dayOfWeek = SeriesPublishedDaysOfWeek.WED, + series = listOf(series(22L)) + ), + null + ) + ) + ) + viewModel.loadContents() + viewModel.changeType(MainContentAllType.SERIES) + + viewModel.changeSort(ContentSort.POPULAR) + + val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content + assertEquals(MainContentAllType.SERIES, state.selectedType) + assertEquals(ContentSort.POPULAR, state.selectedSort) + assertEquals(SeriesPublishedDaysOfWeek.WED, state.selectedDayOfWeek) + assertEquals(0, state.page) + verifyGetContents( + type = MainContentAllType.SERIES, + sort = ContentSort.POPULAR, + page = 0, + dayOfWeek = SeriesPublishedDaysOfWeek.WED + ) + } + + @Test + fun `hasNext가 true이면 loadMore는 다음 페이지를 append한다`() { + stubGetContents( + response = Single.just(ApiResponse(true, response(audios = listOf(audio(1L)), hasNext = true), null)) + ) + stubGetContents( + page = 1, + response = Single.just(ApiResponse(true, response(page = 1, audios = listOf(audio(2L)), hasNext = false), null)) + ) + viewModel.loadContents() + + viewModel.loadMore() + + val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content + assertEquals(1, state.page) + assertEquals(listOf(1L, 2L), state.audioItems.map { it.audioContentId }) + assertFalse(state.hasNext) + verifyGetContents(page = 1) + } + + @Test + fun `loadMore 요청이 진행 중이면 중복 요청하지 않는다`() { + val pending = SingleSubject.create>() + stubGetContents( + response = Single.just(ApiResponse(true, response(audios = listOf(audio(1L)), hasNext = true), null)) + ) + stubGetContents(page = 1, response = pending) + viewModel.loadContents() + + viewModel.loadMore() + viewModel.loadMore() + + val loadingState = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content + assertTrue(loadingState.isLoadingMore) + verifyGetContents(page = 1, times = 1) + pending.onSuccess(ApiResponse(true, response(page = 1, audios = listOf(audio(2L)), hasNext = false), null)) + } + + @Test + fun `loadMore 실패는 기존 목록을 유지하고 pagination error를 표시한다`() { + stubGetContents( + response = Single.just(ApiResponse(true, response(audios = listOf(audio(1L)), hasNext = true), null)) + ) + stubGetContents(page = 1, response = Single.just(ApiResponse(false, null, "failed"))) + viewModel.loadContents() + + viewModel.loadMore() + + val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content + assertEquals(listOf(1L), state.audioItems.map { it.audioContentId }) + assertFalse(state.isLoadingMore) + assertEquals("failed", state.paginationErrorMessage) + } + + @Test + fun `type 변경 후 이전 응답이 늦게 도착하면 현재 목록에 반영하지 않는다`() { + val oldTypePending = SingleSubject.create>() + stubGetContents(response = oldTypePending) + stubGetContents( + type = MainContentAllType.FREE, + response = Single.just(ApiResponse(true, response(type = MainContentAllType.FREE, audios = listOf(audio(10L))), null)) + ) + + viewModel.loadContents() + viewModel.changeType(MainContentAllType.FREE) + oldTypePending.onSuccess(ApiResponse(true, response(audios = listOf(audio(1L))), null)) + + val state = viewModel.allTabStateLiveData.requireValue() as MainContentAllTabUiState.Content + assertEquals(MainContentAllType.FREE, state.selectedType) + assertEquals(listOf(10L), state.audioItems.map { it.audioContentId }) + } + + private fun stubGetContents( + type: MainContentAllType = MainContentAllType.AUDIO, + sort: ContentSort = ContentSort.LATEST, + page: Int = 0, + size: Int = DEFAULT_PAGE_SIZE, + dayOfWeek: SeriesPublishedDaysOfWeek? = null, + response: Single> + ) { + whenever(repository.getContents("Bearer test-token", type, sort, page, size, dayOfWeek)).thenReturn(response) + } + + private fun verifyGetContents( + type: MainContentAllType = MainContentAllType.AUDIO, + sort: ContentSort = ContentSort.LATEST, + page: Int = 0, + size: Int = DEFAULT_PAGE_SIZE, + dayOfWeek: SeriesPublishedDaysOfWeek? = null, + times: Int? = null + ) { + val verification = times?.let { verify(repository, times(it)) } ?: verify(repository) + verification.getContents("Bearer test-token", type, sort, page, size, dayOfWeek) + } + + private fun response( + type: MainContentAllType = MainContentAllType.AUDIO, + totalCount: Int = 1, + audios: List = emptyList(), + series: List = emptyList(), + sort: ContentSort = ContentSort.LATEST, + dayOfWeek: SeriesPublishedDaysOfWeek? = null, + page: Int = 0, + size: Int = DEFAULT_PAGE_SIZE, + hasNext: Boolean = false + ) = MainContentAllTabResponse( + type = type, + totalCount = totalCount, + audios = audios, + series = series, + sort = sort, + dayOfWeek = dayOfWeek, + page = page, + size = size, + hasNext = hasNext + ) + + private fun audio(id: Long) = MainContentAudioResponse( + audioContentId = id, + title = "오디오 $id", + imageUrl = null, + price = 100, + isAdult = false, + isPointAvailable = false, + isFirstContent = false, + isOriginalSeries = false, + creatorNickname = "크리에이터" + ) + + private fun series(id: Long) = MainContentSeriesResponse( + seriesId = id, + title = "시리즈 $id", + coverImageUrl = null, + creatorNickname = "크리에이터", + isOriginal = false, + isAdult = false + ) + + private fun LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { value = it } + observeForever(observer) + removeObserver(observer) + return value + } + + private fun setImmediateRxSchedulers() { + val trampoline = { _: Scheduler -> Schedulers.trampoline() } + RxJavaPlugins.setIoSchedulerHandler(trampoline) + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() } + } + + private companion object { + const val DEFAULT_PAGE_SIZE = 20 + } +}