From f4e46f9d206ef578ef747148b668373f170dd7e5 Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 24 Jun 2026 14:45:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(content):=20=EB=9E=AD=ED=82=B9=20ViewModel?= =?UTF-8?q?=EC=9D=84=20=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 --- .../main/content/ContentRankingViewModel.kt | 97 ++++++ .../content/model/AudioRankingsUiState.kt | 20 ++ .../content/ContentRankingViewModelTest.kt | 285 ++++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModel.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRankingsUiState.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModelTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModel.kt new file mode 100644 index 00000000..d6fabea6 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModel.kt @@ -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() + private var latestRequestId = 0L + + private val _rankingStateLiveData = MutableLiveData() + val rankingStateLiveData: LiveData + get() = _rankingStateLiveData + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData + get() = _isLoading + + private val _selectedTypeLiveData = MutableLiveData(AudioRankingType.WEEKLY_POPULAR) + val selectedTypeLiveData: LiveData + 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}" +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRankingsUiState.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRankingsUiState.kt new file mode 100644 index 00000000..259f5191 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRankingsUiState.kt @@ -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 + ) : AudioRankingsUiState() + + data class Empty(val type: AudioRankingType) : AudioRankingsUiState() + + data class Error( + val type: AudioRankingType, + val message: String? + ) : AudioRankingsUiState() +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModelTest.kt new file mode 100644 index 00000000..43fd11f9 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentRankingViewModelTest.kt @@ -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>() + 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>() + val pendingRising = SingleSubject.create>() + 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 = 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 LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { 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() + private val responses = ArrayDeque>>() + + fun enqueueSuccess(response: AudioRankingResponse) { + enqueue(ApiResponse(true, response, null)) + } + + fun enqueueSuccess(response: ApiResponse) { + enqueue(Single.just(response)) + } + + fun enqueue(response: ApiResponse) { + enqueue(Single.just(response)) + } + + fun enqueue(response: Single>) { + responses.addLast(response) + } + + override fun getRankings( + authHeader: String, + type: AudioRankingType + ): Single> { + calls.add(ApiCall(authHeader, type)) + return responses.removeFirst() + } + } +}