feat(content): 랭킹 ViewModel을 추가한다
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
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.SharedPreferenceManager
|
||||||
|
import kr.co.vividnext.sodalive.common.ToastMessage
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingType
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingsRepository
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.content.model.AudioRankingsUiState
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.content.model.toContentRankingItems
|
||||||
|
|
||||||
|
class ContentRankingViewModel(
|
||||||
|
private val repository: AudioRankingsRepository
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
private val cachedStates = mutableMapOf<AudioRankingType, AudioRankingsUiState>()
|
||||||
|
private var latestRequestId = 0L
|
||||||
|
|
||||||
|
private val _rankingStateLiveData = MutableLiveData<AudioRankingsUiState>()
|
||||||
|
val rankingStateLiveData: LiveData<AudioRankingsUiState>
|
||||||
|
get() = _rankingStateLiveData
|
||||||
|
|
||||||
|
private val _toastLiveData = MutableLiveData<ToastMessage?>()
|
||||||
|
val toastLiveData: LiveData<ToastMessage?>
|
||||||
|
get() = _toastLiveData
|
||||||
|
|
||||||
|
private val _isLoading = MutableLiveData(false)
|
||||||
|
val isLoading: LiveData<Boolean>
|
||||||
|
get() = _isLoading
|
||||||
|
|
||||||
|
private val _selectedTypeLiveData = MutableLiveData(AudioRankingType.WEEKLY_POPULAR)
|
||||||
|
val selectedTypeLiveData: LiveData<AudioRankingType>
|
||||||
|
get() = _selectedTypeLiveData
|
||||||
|
|
||||||
|
fun loadRankings(type: AudioRankingType, force: Boolean = false) {
|
||||||
|
_selectedTypeLiveData.value = type
|
||||||
|
val requestId = ++latestRequestId
|
||||||
|
val cachedState = cachedStates[type]
|
||||||
|
if (!force && cachedState != null) {
|
||||||
|
_isLoading.value = false
|
||||||
|
_rankingStateLiveData.value = cachedState
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading.value = true
|
||||||
|
_rankingStateLiveData.value = AudioRankingsUiState.Loading
|
||||||
|
|
||||||
|
compositeDisposable.add(
|
||||||
|
repository.getRankings(token = authToken(), type = type)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
{
|
||||||
|
if (!isCurrentRequest(requestId, type)) return@subscribe
|
||||||
|
_isLoading.value = false
|
||||||
|
val data = it.data
|
||||||
|
if (it.success && data != null) {
|
||||||
|
if (data.type != type) return@subscribe
|
||||||
|
val items = data.toContentRankingItems()
|
||||||
|
val state = if (items.isEmpty()) {
|
||||||
|
AudioRankingsUiState.Empty(type)
|
||||||
|
} else {
|
||||||
|
AudioRankingsUiState.Content(type, items)
|
||||||
|
}
|
||||||
|
cachedStates[type] = state
|
||||||
|
_rankingStateLiveData.value = state
|
||||||
|
} else {
|
||||||
|
showUnknownError(type, it.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
if (!isCurrentRequest(requestId, type)) return@subscribe
|
||||||
|
_isLoading.value = false
|
||||||
|
it.message?.let { message -> Logger.e(message) }
|
||||||
|
showUnknownError(type, it.message)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCurrentRequest(requestId: Long, type: AudioRankingType): Boolean {
|
||||||
|
return requestId == latestRequestId && _selectedTypeLiveData.value == type
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showUnknownError(type: AudioRankingType, message: String?) {
|
||||||
|
if (_selectedTypeLiveData.value != type) return
|
||||||
|
_rankingStateLiveData.value = AudioRankingsUiState.Error(type = type, message = message)
|
||||||
|
_toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.main.content.model
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingType
|
||||||
|
import kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingItem
|
||||||
|
|
||||||
|
sealed class AudioRankingsUiState {
|
||||||
|
data object Loading : AudioRankingsUiState()
|
||||||
|
|
||||||
|
data class Content(
|
||||||
|
val type: AudioRankingType,
|
||||||
|
val items: List<ContentRankingItem>
|
||||||
|
) : AudioRankingsUiState()
|
||||||
|
|
||||||
|
data class Empty(val type: AudioRankingType) : AudioRankingsUiState()
|
||||||
|
|
||||||
|
data class Error(
|
||||||
|
val type: AudioRankingType,
|
||||||
|
val message: String?
|
||||||
|
) : AudioRankingsUiState()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user