feat(content): 랭킹 ViewModel을 추가한다

This commit is contained in:
2026-06-24 14:45:10 +09:00
parent f2996f599a
commit f4e46f9d20
3 changed files with 402 additions and 0 deletions

View File

@@ -0,0 +1,285 @@
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.R
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingItemResponse
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingResponse
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingType
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingsApi
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingsRepository
import kr.co.vividnext.sodalive.v2.main.content.model.AudioRankingsUiState
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class ContentRankingViewModelTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var api: FakeAudioRankingsApi
private lateinit var viewModel: ContentRankingViewModel
@Before
fun setUp() {
setImmediateRxSchedulers()
SharedPreferenceManager.resetForTest()
SharedPreferenceManager.init(context)
SharedPreferenceManager.token = "test-token"
api = FakeAudioRankingsApi()
viewModel = ContentRankingViewModel(
repository = AudioRankingsRepository(api)
)
}
@After
fun tearDown() {
RxJavaPlugins.reset()
RxAndroidPlugins.reset()
SharedPreferenceManager.resetForTest()
}
@Test
fun `초기 selected type은 WEEKLY_POPULAR다`() {
assertEquals(AudioRankingType.WEEKLY_POPULAR, viewModel.selectedTypeLiveData.requireValue())
}
@Test
fun `랭킹 로드는 token과 type으로 API를 요청한다`() {
api.enqueueSuccess(response(type = AudioRankingType.WEEKLY_POPULAR))
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
assertEquals(listOf(ApiCall("Bearer test-token", AudioRankingType.WEEKLY_POPULAR)), api.calls)
}
@Test
fun `success와 item이 있으면 Content를 emit한다`() {
api.enqueueSuccess(
response(
type = AudioRankingType.RISING,
items = listOf(item(contentId = 10L, rank = 1, title = "오디오"))
)
)
viewModel.loadRankings(AudioRankingType.RISING)
val state = viewModel.rankingStateLiveData.requireValue() as AudioRankingsUiState.Content
assertEquals(AudioRankingType.RISING, state.type)
assertEquals(listOf("10"), state.items.map { it.contentId })
assertEquals("오디오", state.items.first().contentName)
assertFalse(viewModel.isLoading.requireValue() ?: true)
}
@Test
fun `success와 item이 비어 있으면 Empty를 emit한다`() {
api.enqueueSuccess(response(type = AudioRankingType.REVENUE, items = emptyList()))
viewModel.loadRankings(AudioRankingType.REVENUE)
val state = viewModel.rankingStateLiveData.requireValue() as AudioRankingsUiState.Empty
assertEquals(AudioRankingType.REVENUE, state.type)
}
@Test
fun `API failure는 Error와 unknown error toast를 emit한다`() {
api.enqueueSuccess(ApiResponse(false, null, "failed"))
viewModel.loadRankings(AudioRankingType.SALES_COUNT)
val state = viewModel.rankingStateLiveData.requireValue() as AudioRankingsUiState.Error
assertEquals(AudioRankingType.SALES_COUNT, state.type)
assertEquals("failed", state.message)
assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId)
}
@Test
fun `Throwable은 Error와 unknown error toast를 emit한다`() {
api.enqueue(Single.error(IllegalStateException("network")))
viewModel.loadRankings(AudioRankingType.COMMENT_COUNT)
val state = viewModel.rankingStateLiveData.requireValue() as AudioRankingsUiState.Error
assertEquals(AudioRankingType.COMMENT_COUNT, state.type)
assertEquals("network", state.message)
assertEquals(R.string.common_error_unknown, viewModel.toastLiveData.requireValue()?.resId)
}
@Test
fun `같은 type을 이미 성공 로드했고 force false이면 API를 재호출하지 않는다`() {
api.enqueueSuccess(response(type = AudioRankingType.LIKE_COUNT))
viewModel.loadRankings(AudioRankingType.LIKE_COUNT)
viewModel.loadRankings(AudioRankingType.LIKE_COUNT)
assertEquals(1, api.calls.size)
}
@Test
fun `이미 로드한 type을 다시 선택하면 API 재호출 없이 캐시된 상태를 emit한다`() {
api.enqueueSuccess(response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 1L))))
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
api.enqueueSuccess(response(type = AudioRankingType.RISING, items = listOf(item(contentId = 2L))))
viewModel.loadRankings(AudioRankingType.RISING)
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
val state = viewModel.rankingStateLiveData.requireValue() as AudioRankingsUiState.Content
assertEquals(2, api.calls.size)
assertEquals(AudioRankingType.WEEKLY_POPULAR, state.type)
assertEquals(listOf("1"), state.items.map { it.contentId })
}
@Test
fun `force true이면 같은 type도 다시 호출한다`() {
api.enqueueSuccess(response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 1L))))
api.enqueueSuccess(response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 2L))))
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR, force = true)
assertEquals(2, api.calls.size)
val state = viewModel.rankingStateLiveData.requireValue() as AudioRankingsUiState.Content
assertEquals(listOf("2"), state.items.map { it.contentId })
}
@Test
fun `선택 type 변경 전 응답이 늦게 도착하면 화면 상태를 덮어쓰지 않는다`() {
val pendingWeekly = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
api.enqueue(pendingWeekly)
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
api.enqueueSuccess(response(type = AudioRankingType.RISING, items = listOf(item(contentId = 2L))))
viewModel.loadRankings(AudioRankingType.RISING)
pendingWeekly.onSuccess(
ApiResponse(
true,
response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 1L))),
null
)
)
val state = viewModel.rankingStateLiveData.requireValue() as AudioRankingsUiState.Content
assertEquals(AudioRankingType.RISING, state.type)
assertEquals(listOf("2"), state.items.map { it.contentId })
}
@Test
fun `이전 type 응답이 늦게 도착해도 현재 로딩 상태를 끄지 않는다`() {
val pendingWeekly = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
val pendingRising = SingleSubject.create<ApiResponse<AudioRankingResponse>>()
api.enqueue(pendingWeekly)
viewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
api.enqueue(pendingRising)
viewModel.loadRankings(AudioRankingType.RISING)
pendingWeekly.onSuccess(
ApiResponse(
true,
response(type = AudioRankingType.WEEKLY_POPULAR, items = listOf(item(contentId = 1L))),
null
)
)
assertEquals(true, viewModel.isLoading.requireValue())
pendingRising.onSuccess(
ApiResponse(
true,
response(type = AudioRankingType.RISING, items = listOf(item(contentId = 2L))),
null
)
)
assertEquals(false, viewModel.isLoading.requireValue())
}
private fun setImmediateRxSchedulers() {
val trampoline = { _: Scheduler -> Schedulers.trampoline() }
RxJavaPlugins.setIoSchedulerHandler(trampoline)
RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
}
private fun response(
type: AudioRankingType,
items: List<AudioRankingItemResponse> = listOf(item())
) = AudioRankingResponse(
showRankChange = false,
type = type,
items = items
)
private fun item(
contentId: Long = 1L,
rank: Int = 1,
title: String = "랭킹 오디오"
) = AudioRankingItemResponse(
contentId = contentId,
title = title,
creatorNickname = "크리에이터",
rank = rank,
rankChange = 0,
isNew = false,
coverImageUrl = "https://example.com/cover.png"
)
private fun <T> LiveData<T>.requireValue(): T? {
var value: T? = null
val observer = Observer<T> { value = it }
observeForever(observer)
removeObserver(observer)
return value
}
private data class ApiCall(
val token: String,
val type: AudioRankingType
)
private class FakeAudioRankingsApi : AudioRankingsApi {
val calls = mutableListOf<ApiCall>()
private val responses = ArrayDeque<Single<ApiResponse<AudioRankingResponse>>>()
fun enqueueSuccess(response: AudioRankingResponse) {
enqueue(ApiResponse(true, response, null))
}
fun enqueueSuccess(response: ApiResponse<AudioRankingResponse>) {
enqueue(Single.just(response))
}
fun enqueue(response: ApiResponse<AudioRankingResponse>) {
enqueue(Single.just(response))
}
fun enqueue(response: Single<ApiResponse<AudioRankingResponse>>) {
responses.addLast(response)
}
override fun getRankings(
authHeader: String,
type: AudioRankingType
): Single<ApiResponse<AudioRankingResponse>> {
calls.add(ApiCall(authHeader, type))
return responses.removeFirst()
}
}
}