feat(creator): 시리즈 탭 상태 관리를 추가한다

This commit is contained in:
2026-06-20 02:53:24 +09:00
parent 185c92e9af
commit c25d4cd161
7 changed files with 826 additions and 0 deletions

View File

@@ -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.CreatorChannelApi
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository 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.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.MainV2ViewModel
import kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModel import kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModel
import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomApi 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 { CreatorChannelHomeViewModel(get()) }
viewModel { CreatorChannelLiveViewModel(get()) } viewModel { CreatorChannelLiveViewModel(get()) }
viewModel { CreatorChannelAudioViewModel(get()) } viewModel { CreatorChannelAudioViewModel(get()) }
viewModel { CreatorChannelSeriesViewModel(get()) }
viewModel { PushNotificationListViewModel(get()) } viewModel { PushNotificationListViewModel(get()) }
viewModel { CharacterTabViewModel(get()) } viewModel { CharacterTabViewModel(get()) }
viewModel { CharacterDetailViewModel(get()) } viewModel { CharacterDetailViewModel(get()) }

View File

@@ -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<CreatorChannelSeriesUiState>()
val seriesStateLiveData: LiveData<CreatorChannelSeriesUiState>
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<CreatorChannelSeriesTabResponse>) -> 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<CreatorChannelSeriesItemUiModel>
) = 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<CreatorChannelSeriesItemUiModel>,
val page: Int,
val size: Int,
val hasNext: Boolean,
val isLoadingMore: Boolean = false,
val paginationErrorMessage: String? = null
) : CreatorChannelSeriesUiState
}

View File

@@ -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<CreatorChannelSeriesResponse>.toSeriesItemUiModels(
isOwner: Boolean
): List<CreatorChannelSeriesItemUiModel> = 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)
)
}

View File

@@ -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
)

View File

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

View File

@@ -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<ApiResponse<CreatorChannelSeriesTabResponse>>()
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<ApiResponse<CreatorChannelSeriesTabResponse>>()
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<ApiResponse<CreatorChannelSeriesTabResponse>>
) {
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<Long>,
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 <T> LiveData<T>.requireValue(): T? {
var value: T? = null
val observer = Observer<T> { value = it }
observeForever(observer)
removeObserver(observer)
return value
}
}

View File

@@ -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<ApiResponse<CreatorChannelSeriesTabResponse>>
) {
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<Long> = listOf(1L),
hasNext: Boolean = false,
blankTitleIds: Set<Long> = 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 <T> LiveData<T>.requireValue(): T? {
var value: T? = null
val observer = Observer<T> { value = it }
observeForever(observer)
removeObserver(observer)
return value
}
}