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

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