From c25d4cd161526685b5e514197ea4579c82cb38cf Mon Sep 17 00:00:00 2001 From: klaus Date: Sat, 20 Jun 2026 02:53:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EC=8B=9C=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=ED=83=AD=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=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 + .../series/CreatorChannelSeriesViewModel.kt | 175 +++++++++++++ .../model/CreatorChannelSeriesMappers.kt | 45 ++++ .../model/CreatorChannelSeriesUiModels.kt | 18 ++ .../series/CreatorChannelSeriesMapperTest.kt | 126 +++++++++ .../CreatorChannelSeriesPaginationTest.kt | 240 ++++++++++++++++++ .../CreatorChannelSeriesViewModelTest.kt | 220 ++++++++++++++++ 7 files changed, 826 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesViewModel.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/model/CreatorChannelSeriesMappers.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/model/CreatorChannelSeriesUiModels.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesMapperTest.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesPaginationTest.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesViewModelTest.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 eb1d88bc..2fd87478 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 @@ -181,6 +181,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.audio.CreatorChannelAudioView 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.creator.channel.series.CreatorChannelSeriesViewModel 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 @@ -412,6 +413,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) { viewModel { CreatorChannelHomeViewModel(get()) } viewModel { CreatorChannelLiveViewModel(get()) } viewModel { CreatorChannelAudioViewModel(get()) } + viewModel { CreatorChannelSeriesViewModel(get()) } viewModel { PushNotificationListViewModel(get()) } viewModel { CharacterTabViewModel(get()) } viewModel { CharacterDetailViewModel(get()) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesViewModel.kt new file mode 100644 index 00000000..7e9aad6b --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesViewModel.kt @@ -0,0 +1,175 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.series + +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.CreatorChannelRepository +import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesTabResponse +import kr.co.vividnext.sodalive.v2.creator.channel.series.model.CreatorChannelSeriesItemUiModel +import kr.co.vividnext.sodalive.v2.creator.channel.series.model.toSeriesItemUiModels + +class CreatorChannelSeriesViewModel( + private val repository: CreatorChannelRepository +) : BaseViewModel() { + + private val _seriesStateLiveData = MutableLiveData() + val seriesStateLiveData: LiveData + get() = _seriesStateLiveData + + private var creatorId: Long = 0L + private var isOwner: Boolean = false + private var selectedSort: ContentSort = ContentSort.LATEST + private var requestGeneration: Int = 0 + + fun loadSeries(creatorId: Long, isOwner: Boolean) { + if (creatorId <= 0) return + val shouldSkipReload = this.creatorId == creatorId && this.isOwner == isOwner && _seriesStateLiveData.value != null + if (shouldSkipReload) return + + this.creatorId = creatorId + this.isOwner = isOwner + loadFirstPage(selectedSort) + } + + fun changeSort(sort: ContentSort) { + if (sort == selectedSort) return + if (creatorId <= 0) return + + selectedSort = sort + loadFirstPage(sort) + } + + fun retrySeries() { + if (creatorId <= 0) return + + loadFirstPage(selectedSort) + } + + fun loadMore() { + val content = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content ?: return + if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return + + val generation = requestGeneration + _seriesStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null) + requestSeries(page = content.page + 1, sort = content.selectedSort, generation = generation) { response -> + val data = response.data + val current = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content ?: content + if (response.success && data != null) { + _seriesStateLiveData.value = current.copy( + series = current.series + data.series.toSeriesItemUiModels(isOwner), + page = data.page, + size = data.size, + hasNext = data.hasNext, + isLoadingMore = false + ) + } else { + _seriesStateLiveData.value = current.copy( + isLoadingMore = false, + paginationErrorMessage = response.message + ) + } + } + } + + fun consumePaginationErrorMessage() { + val content = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content ?: return + if (content.paginationErrorMessage == null) return + + _seriesStateLiveData.value = content.copy(paginationErrorMessage = null) + } + + private fun loadFirstPage(sort: ContentSort) { + val generation = ++requestGeneration + _seriesStateLiveData.value = CreatorChannelSeriesUiState.Loading + requestSeries(page = FIRST_PAGE, sort = sort, generation = generation) { response -> + val data = response.data + if (response.success && data != null) { + val series = data.series.toSeriesItemUiModels(isOwner) + _seriesStateLiveData.value = if (series.isEmpty() || data.seriesCount == 0) { + CreatorChannelSeriesUiState.Empty + } else { + data.toContentState(series = series) + } + } else { + _seriesStateLiveData.value = CreatorChannelSeriesUiState.Error(response.message) + } + } + } + + private fun requestSeries( + page: Int, + sort: ContentSort, + generation: Int, + onSuccess: (ApiResponse) -> Unit + ) { + compositeDisposable.add( + repository.getSeries( + 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 = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content + _seriesStateLiveData.value = if (current != null && page > FIRST_PAGE) { + current.copy(isLoadingMore = false, paginationErrorMessage = it.message) + } else { + CreatorChannelSeriesUiState.Error(it.message) + } + } + ) + ) + } + + private fun CreatorChannelSeriesTabResponse.toContentState( + series: List + ) = CreatorChannelSeriesUiState.Content( + seriesCount = seriesCount, + selectedSort = sort, + series = series, + page = page, + size = size, + hasNext = hasNext + ) + + private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}" + + companion object { + val DEFAULT_PAGE_SIZE = 20 + private const val FIRST_PAGE = 0 + } +} + +sealed interface CreatorChannelSeriesUiState { + data object Loading : CreatorChannelSeriesUiState + data object Empty : CreatorChannelSeriesUiState + data class Error(val message: String?) : CreatorChannelSeriesUiState + data class Content( + val seriesCount: Int, + val selectedSort: ContentSort, + val series: List, + val page: Int, + val size: Int, + val hasNext: Boolean, + val isLoadingMore: Boolean = false, + val paginationErrorMessage: String? = null + ) : CreatorChannelSeriesUiState +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/model/CreatorChannelSeriesMappers.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/model/CreatorChannelSeriesMappers.kt new file mode 100644 index 00000000..b3e45f93 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/model/CreatorChannelSeriesMappers.kt @@ -0,0 +1,45 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.series.model + +import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesResponse + +private const val BULLET_SEPARATOR = " • " +private const val STATUS_PROCEEDING = "연재" +private const val STATUS_COMPLETED = "완결" + +fun List.toSeriesItemUiModels( + isOwner: Boolean +): List = mapNotNull { it.toSeriesItemUiModel(isOwner) } + +private fun CreatorChannelSeriesResponse.toSeriesItemUiModel(isOwner: Boolean): CreatorChannelSeriesItemUiModel? { + if (title.isBlank()) return null + + return CreatorChannelSeriesItemUiModel( + seriesId = seriesId, + title = title, + subtitle = subtitle(), + coverImageUrl = coverImageUrl, + showOriginalTag = isOriginal, + showAdultBadge = isAdult, + progress = toProgressUiModel(isOwner) + ) +} + +private fun CreatorChannelSeriesResponse.subtitle(): String = listOfNotNull( + publishedDaysOfWeek?.takeIf { it.isNotBlank() }, + "총 ${contentCount}화", + if (isProceeding) STATUS_PROCEEDING else STATUS_COMPLETED +).joinToString(BULLET_SEPARATOR) + +private fun CreatorChannelSeriesResponse.toProgressUiModel(isOwner: Boolean): CreatorChannelSeriesProgressUiModel? { + if (isOwner) return null + val purchasedCount = purchasedContentCount ?: return null + val paidCount = paidContentCount ?: return null + val ratePercent = purchasedPaidContentRate ?: return null + + return CreatorChannelSeriesProgressUiModel( + purchasedCount = purchasedCount, + paidCount = paidCount, + ratePercent = ratePercent, + progressScale = (ratePercent / 100f).toFloat().coerceIn(0f, 1f) + ) +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/model/CreatorChannelSeriesUiModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/model/CreatorChannelSeriesUiModels.kt new file mode 100644 index 00000000..8a83b3f9 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/series/model/CreatorChannelSeriesUiModels.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.series.model + +data class CreatorChannelSeriesItemUiModel( + val seriesId: Long, + val title: String, + val subtitle: String, + val coverImageUrl: String?, + val showOriginalTag: Boolean, + val showAdultBadge: Boolean, + val progress: CreatorChannelSeriesProgressUiModel? +) + +data class CreatorChannelSeriesProgressUiModel( + val purchasedCount: Int, + val paidCount: Int, + val ratePercent: Double, + val progressScale: Float +) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesMapperTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesMapperTest.kt new file mode 100644 index 00000000..f288e27e --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesMapperTest.kt @@ -0,0 +1,126 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.series + +import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesResponse +import kr.co.vividnext.sodalive.v2.creator.channel.series.model.CreatorChannelSeriesProgressUiModel +import kr.co.vividnext.sodalive.v2.creator.channel.series.model.toSeriesItemUiModels +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CreatorChannelSeriesMapperTest { + + @Test + fun `isProceeding true이면 subtitle에 연재가 포함된다`() { + val item = listOf(series(isProceeding = true)).toSeriesItemUiModels(isOwner = false).single() + + assertTrue(item.subtitle.contains("연재")) + } + + @Test + fun `isProceeding false이면 subtitle에 완결이 포함된다`() { + val item = listOf(series(isProceeding = false)).toSeriesItemUiModels(isOwner = false).single() + + assertTrue(item.subtitle.contains("완결")) + } + + @Test + fun `publishedDaysOfWeek contentCount 진행 상태를 bullet 형식으로 조합한다`() { + val item = listOf( + series(publishedDaysOfWeek = "매주 월", contentCount = 45, isProceeding = true) + ).toSeriesItemUiModels(isOwner = false).single() + + assertEquals("매주 월 • 총 45화 • 연재", item.subtitle) + } + + @Test + fun `publishedDaysOfWeek가 blank이면 빈 bullet 없이 조합한다`() { + val item = listOf( + series(publishedDaysOfWeek = " ", contentCount = 45, isProceeding = true) + ).toSeriesItemUiModels(isOwner = false).single() + + assertEquals("총 45화 • 연재", item.subtitle) + } + + @Test + fun `original과 adult flag를 item 표시 flag로 매핑한다`() { + val item = listOf(series(isOriginal = true, isAdult = true)).toSeriesItemUiModels(isOwner = false).single() + + assertTrue(item.showOriginalTag) + assertTrue(item.showAdultBadge) + } + + @Test + fun `내 채널이면 progress가 생성되지 않는다`() { + val item = listOf(series()).toSeriesItemUiModels(isOwner = true).single() + + assertNull(item.progress) + } + + @Test + fun `내 채널이 아니고 progress nullable field가 모두 있으면 progress가 생성된다`() { + val item = listOf( + series(purchasedContentCount = 12, paidContentCount = 45, purchasedPaidContentRate = 40.0) + ).toSeriesItemUiModels(isOwner = false).single() + + assertEquals(CreatorChannelSeriesProgressUiModel(12, 45, 40.0, 0.4f), item.progress) + } + + @Test + fun `내 채널이 아니어도 progress nullable field 중 하나라도 null이면 progress가 null이다`() { + val items = listOf( + series(seriesId = 1L, purchasedContentCount = null, paidContentCount = 45, purchasedPaidContentRate = 40.0), + series(seriesId = 2L, purchasedContentCount = 12, paidContentCount = null, purchasedPaidContentRate = 40.0), + series(seriesId = 3L, purchasedContentCount = 12, paidContentCount = 45, purchasedPaidContentRate = null) + ).toSeriesItemUiModels(isOwner = false) + + assertEquals(listOf(null, null, null), items.map { it.progress }) + } + + @Test + fun `rate가 0 미만이면 progressScale은 0으로 clamp된다`() { + val item = listOf(series(purchasedPaidContentRate = -10.0)).toSeriesItemUiModels(isOwner = false).single() + + assertEquals(0f, item.progress?.progressScale) + } + + @Test + fun `rate가 100 초과이면 progressScale은 1로 clamp된다`() { + val item = listOf(series(purchasedPaidContentRate = 120.0)).toSeriesItemUiModels(isOwner = false).single() + + assertEquals(1f, item.progress?.progressScale) + } + + @Test + fun `title이 blank이면 표시 가능한 item에서 제외한다`() { + val items = listOf(series(title = " ")).toSeriesItemUiModels(isOwner = false) + + assertTrue(items.isEmpty()) + } + + private fun series( + seriesId: Long = 1L, + title: String = "시리즈", + coverImageUrl: String? = "https://example.com/series.png", + publishedDaysOfWeek: String? = "매주 월", + contentCount: Int = 45, + isProceeding: Boolean = true, + isOriginal: Boolean = false, + isAdult: Boolean = false, + purchasedContentCount: Int? = 12, + paidContentCount: Int? = 45, + purchasedPaidContentRate: Double? = 40.0 + ) = CreatorChannelSeriesResponse( + seriesId = seriesId, + title = title, + coverImageUrl = coverImageUrl, + publishedDaysOfWeek = publishedDaysOfWeek, + contentCount = contentCount, + isProceeding = isProceeding, + isOriginal = isOriginal, + isAdult = isAdult, + purchasedContentCount = purchasedContentCount, + paidContentCount = paidContentCount, + purchasedPaidContentRate = purchasedPaidContentRate + ) +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesPaginationTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesPaginationTest.kt new file mode 100644 index 00000000..60567fb0 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesPaginationTest.kt @@ -0,0 +1,240 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.series + +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.CreatorChannelRepository +import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesResponse +import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesTabResponse +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 CreatorChannelSeriesPaginationTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var repository: CreatorChannelRepository + private lateinit var viewModel: CreatorChannelSeriesViewModel + + @Before + fun setUp() { + setImmediateRxSchedulers() + SharedPreferenceManager.resetForTest() + SharedPreferenceManager.init(context) + SharedPreferenceManager.token = "test-token" + repository = org.mockito.kotlin.mock() + viewModel = CreatorChannelSeriesViewModel(repository) + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + SharedPreferenceManager.resetForTest() + } + + @Test + fun `hasNext가 true이면 다음 페이지는 마지막 성공 응답의 page plus 1로 요청하고 append한다`() { + stubGetSeries( + page = 0, + response = Single.just( + ApiResponse(true, seriesResponse(0, ids = listOf(1L), hasNext = true), null) + ) + ) + stubGetSeries( + page = 1, + response = Single.just( + ApiResponse(true, seriesResponse(1, ids = listOf(2L), hasNext = false), null) + ) + ) + viewModel.loadSeries(100L, isOwner = false) + + viewModel.loadMore() + + val state = viewModel.seriesStateLiveData.requireValue() as CreatorChannelSeriesUiState.Content + assertEquals(1, state.page) + assertEquals(listOf(1L, 2L), state.series.map { it.seriesId }) + assertFalse(state.hasNext) + verifyGetSeries(page = 1) + } + + @Test + fun `다음 페이지 로딩 중 중복 load-more 요청은 막는다`() { + val pending = SingleSubject.create>() + stubGetSeries( + page = 0, + response = Single.just( + ApiResponse(true, seriesResponse(0, ids = listOf(1L), hasNext = true), null) + ) + ) + stubGetSeries(page = 1, response = pending) + viewModel.loadSeries(100L, isOwner = false) + + viewModel.loadMore() + viewModel.loadMore() + + val loadingState = viewModel.seriesStateLiveData.requireValue() as CreatorChannelSeriesUiState.Content + assertTrue(loadingState.isLoadingMore) + verifyGetSeries(page = 1, times = 1) + pending.onSuccess( + ApiResponse(true, seriesResponse(1, ids = listOf(2L), hasNext = false), null) + ) + } + + @Test + fun `다음 페이지 실패는 기존 목록을 유지하고 pagination error를 표시한다`() { + stubGetSeries( + page = 0, + response = Single.just( + ApiResponse(true, seriesResponse(0, ids = listOf(1L), hasNext = true), null) + ) + ) + stubGetSeries(page = 1, response = Single.just(ApiResponse(false, null, "failed"))) + viewModel.loadSeries(100L, isOwner = false) + + viewModel.loadMore() + + val state = viewModel.seriesStateLiveData.requireValue() as CreatorChannelSeriesUiState.Content + assertEquals(listOf(1L), state.series.map { it.seriesId }) + assertFalse(state.isLoadingMore) + assertEquals("failed", state.paginationErrorMessage) + } + + @Test + fun `pagination error message는 표시 후 clear된다`() { + stubGetSeries( + page = 0, + response = Single.just( + ApiResponse(true, seriesResponse(0, ids = listOf(1L), hasNext = true), null) + ) + ) + stubGetSeries(page = 1, response = Single.just(ApiResponse(false, null, "failed"))) + viewModel.loadSeries(100L, isOwner = false) + viewModel.loadMore() + + viewModel.consumePaginationErrorMessage() + + val state = viewModel.seriesStateLiveData.requireValue() as CreatorChannelSeriesUiState.Content + assertEquals(null, state.paginationErrorMessage) + } + + @Test + fun `이전 load-more 응답은 이후 정렬 변경 목록에 append되지 않는다`() { + val nextPagePending = SingleSubject.create>() + stubGetSeries( + page = 0, + response = Single.just( + ApiResponse(true, seriesResponse(0, ids = listOf(1L), hasNext = true), null) + ) + ) + stubGetSeries(page = 1, response = nextPagePending) + stubGetSeries( + page = 0, + sort = ContentSort.POPULAR, + response = Single.just( + ApiResponse( + true, + seriesResponse(0, sort = ContentSort.POPULAR, ids = listOf(10L), hasNext = false), + null + ) + ) + ) + viewModel.loadSeries(100L, isOwner = false) + + viewModel.loadMore() + viewModel.changeSort(ContentSort.POPULAR) + nextPagePending.onSuccess( + ApiResponse(true, seriesResponse(1, ids = listOf(2L), hasNext = false), null) + ) + + val state = viewModel.seriesStateLiveData.requireValue() as CreatorChannelSeriesUiState.Content + assertEquals(ContentSort.POPULAR, state.selectedSort) + assertEquals(listOf(10L), state.series.map { it.seriesId }) + } + + private fun stubGetSeries( + page: Int, + sort: ContentSort = ContentSort.LATEST, + response: Single> + ) { + whenever( + repository.getSeries( + 100L, + page, + CreatorChannelSeriesViewModel.DEFAULT_PAGE_SIZE, + sort, + "Bearer test-token" + ) + ).thenReturn(response) + } + + private fun verifyGetSeries(page: Int, sort: ContentSort = ContentSort.LATEST, times: Int? = null) { + val verification = times?.let { verify(repository, times(it)) } ?: verify(repository) + verification.getSeries(100L, page, CreatorChannelSeriesViewModel.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 seriesResponse( + page: Int, + sort: ContentSort = ContentSort.LATEST, + ids: List, + hasNext: Boolean + ) = CreatorChannelSeriesTabResponse( + seriesCount = ids.size, + series = ids.map { series(it) }, + sort = sort, + page = page, + size = CreatorChannelSeriesViewModel.DEFAULT_PAGE_SIZE, + hasNext = hasNext + ) + + private fun series(id: Long) = CreatorChannelSeriesResponse( + seriesId = id, + title = "시리즈 $id", + coverImageUrl = null, + publishedDaysOfWeek = "매주 월", + contentCount = 45, + isProceeding = true, + isOriginal = false, + isAdult = false, + purchasedContentCount = 12, + paidContentCount = 45, + purchasedPaidContentRate = 40.0 + ) + + 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/series/CreatorChannelSeriesViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesViewModelTest.kt new file mode 100644 index 00000000..b18e686f --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/series/CreatorChannelSeriesViewModelTest.kt @@ -0,0 +1,220 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.series + +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 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.CreatorChannelRepository +import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesResponse +import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesTabResponse +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +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 CreatorChannelSeriesViewModelTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var repository: CreatorChannelRepository + private lateinit var viewModel: CreatorChannelSeriesViewModel + + @Before + fun setUp() { + setImmediateRxSchedulers() + SharedPreferenceManager.resetForTest() + SharedPreferenceManager.init(context) + SharedPreferenceManager.token = "test-token" + repository = org.mockito.kotlin.mock() + viewModel = CreatorChannelSeriesViewModel(repository) + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + SharedPreferenceManager.resetForTest() + } + + @Test + fun `최초 로드는 page 0 size 20 최신순으로 시리즈 API를 호출하고 Content를 emit한다`() { + stubGetSeries(response = Single.just(ApiResponse(true, seriesResponse(), null))) + + viewModel.loadSeries(100L, isOwner = false) + + val state = viewModel.seriesStateLiveData.requireValue() as CreatorChannelSeriesUiState.Content + assertEquals(ContentSort.LATEST, state.selectedSort) + assertEquals(0, state.page) + assertEquals(listOf(1L), state.series.map { it.seriesId }) + verifyGetSeries() + } + + @Test + fun `정렬 변경은 page 0과 선택 sort로 재조회한다`() { + stubGetSeries(response = Single.just(ApiResponse(true, seriesResponse(ids = listOf(1L)), null))) + stubGetSeries( + sort = ContentSort.POPULAR, + response = Single.just(ApiResponse(true, seriesResponse(sort = ContentSort.POPULAR, ids = listOf(2L)), null)) + ) + viewModel.loadSeries(100L, isOwner = false) + + viewModel.changeSort(ContentSort.POPULAR) + + val state = viewModel.seriesStateLiveData.requireValue() as CreatorChannelSeriesUiState.Content + assertEquals(ContentSort.POPULAR, state.selectedSort) + assertEquals(listOf(2L), state.series.map { it.seriesId }) + verifyGetSeries(sort = ContentSort.POPULAR) + } + + @Test + fun `같은 정렬을 다시 선택하면 API를 재호출하지 않는다`() { + stubGetSeries(response = Single.just(ApiResponse(true, seriesResponse(), null))) + viewModel.loadSeries(100L, isOwner = false) + + viewModel.changeSort(ContentSort.LATEST) + + verifyGetSeries(times = 1) + } + + @Test + fun `seriesCount가 0이면 Empty를 emit한다`() { + stubGetSeries(response = Single.just(ApiResponse(true, seriesResponse(seriesCount = 0, ids = listOf(1L)), null))) + + viewModel.loadSeries(100L, isOwner = false) + + assertTrue(viewModel.seriesStateLiveData.requireValue() is CreatorChannelSeriesUiState.Empty) + } + + @Test + fun `표시 가능한 series가 0개이면 Empty를 emit한다`() { + stubGetSeries(response = Single.just(ApiResponse(true, seriesResponse(blankTitleIds = setOf(1L)), null))) + + viewModel.loadSeries(100L, isOwner = false) + assertTrue(viewModel.seriesStateLiveData.requireValue() is CreatorChannelSeriesUiState.Empty) + } + + @Test + fun `내 채널이면 item progress가 null이다`() { + stubGetSeries(response = Single.just(ApiResponse(true, seriesResponse(), null))) + + viewModel.loadSeries(100L, isOwner = true) + + val state = viewModel.seriesStateLiveData.requireValue() as CreatorChannelSeriesUiState.Content + assertNull(state.series.single().progress) + } + + @Test + fun `내 채널이 아니고 progress nullable field가 모두 있으면 progress가 생성된다`() { + stubGetSeries(response = Single.just(ApiResponse(true, seriesResponse(), null))) + + viewModel.loadSeries(100L, isOwner = false) + + val state = viewModel.seriesStateLiveData.requireValue() as CreatorChannelSeriesUiState.Content + assertEquals(12, state.series.single().progress?.purchasedCount) + assertEquals(45, state.series.single().progress?.paidCount) + assertEquals(40.0, state.series.single().progress?.ratePercent) + } + + @Test + fun `creatorId가 0 이하이면 시리즈 API를 호출하지 않는다`() { + viewModel.loadSeries(0L, isOwner = false) + + verify(repository, never()).getSeries(any(), any(), any(), any(), any()) + } + + private fun stubGetSeries( + page: Int = 0, + sort: ContentSort = ContentSort.LATEST, + response: Single> + ) { + whenever( + repository.getSeries( + 100L, + page, + CreatorChannelSeriesViewModel.DEFAULT_PAGE_SIZE, + sort, + "Bearer test-token" + ) + ).thenReturn(response) + } + + private fun verifyGetSeries( + page: Int = 0, + sort: ContentSort = ContentSort.LATEST, + times: Int? = null + ) { + val verification = times?.let { verify(repository, times(it)) } ?: verify(repository) + verification.getSeries( + 100L, + page, + CreatorChannelSeriesViewModel.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 seriesResponse( + page: Int = 0, + sort: ContentSort = ContentSort.LATEST, + seriesCount: Int? = null, + ids: List = listOf(1L), + hasNext: Boolean = false, + blankTitleIds: Set = emptySet() + ) = CreatorChannelSeriesTabResponse( + seriesCount = seriesCount ?: ids.size, + series = ids.map { series(it, title = if (it in blankTitleIds) " " else "시리즈 $it") }, + sort = sort, + page = page, + size = CreatorChannelSeriesViewModel.DEFAULT_PAGE_SIZE, + hasNext = hasNext + ) + + private fun series(id: Long, title: String = "시리즈 $id") = CreatorChannelSeriesResponse( + seriesId = id, + title = title, + coverImageUrl = null, + publishedDaysOfWeek = "매주 월", + contentCount = 45, + isProceeding = true, + isOriginal = false, + isAdult = false, + purchasedContentCount = 12, + paidContentCount = 45, + purchasedPaidContentRate = 40.0 + ) + + private fun LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { value = it } + observeForever(observer) + removeObserver(observer) + return value + } +}