From 0134942172ace08ef5dcbebc7e31a4e359234d83 Mon Sep 17 00:00:00 2001 From: klaus Date: Sat, 27 Jun 2026 08:29:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(content):=20=EC=A0=84=EC=B2=B4=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20ViewModel=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overview/ContentOverviewViewModel.kt | 146 +++++++++ .../overview/ContentOverviewViewModelTest.kt | 293 ++++++++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewViewModel.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewViewModelTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewViewModel.kt new file mode 100644 index 00000000..6ade6496 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewViewModel.kt @@ -0,0 +1,146 @@ +package kr.co.vividnext.sodalive.v2.main.content.overview + +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.ApiResponse +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.common.ToastMessage +import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewPageResponse +import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewRepository +import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewType +import kr.co.vividnext.sodalive.v2.main.content.overview.model.ContentOverviewUiState +import kr.co.vividnext.sodalive.v2.main.content.overview.model.toContent + +class ContentOverviewViewModel( + private val repository: ContentOverviewRepository +) : BaseViewModel() { + + private val _overviewStateLiveData = MutableLiveData() + val overviewStateLiveData: LiveData + get() = _overviewStateLiveData + + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData + get() = _isLoading + + private val _toastLiveData = MutableLiveData() + val toastLiveData: LiveData + get() = _toastLiveData + + private var selectedType: ContentOverviewType = ContentOverviewType.NEW_AND_HOT_AUDIO + private var requestGeneration: Int = 0 + + fun loadFirstPage(type: ContentOverviewType) { + selectedType = type + val generation = ++requestGeneration + _isLoading.value = true + _overviewStateLiveData.value = ContentOverviewUiState.Loading(type) + requestContents(type, FIRST_PAGE, generation) { response -> + _isLoading.value = false + val data = response.data + if (response.success && data != null) { + val content = data.toContent() + _overviewStateLiveData.value = if (content.items.isEmpty()) { + ContentOverviewUiState.Empty(content.type, content.totalCount) + } else { + content + } + } else { + showFirstPageError(type, response.message) + } + } + } + + fun loadMore() { + val content = _overviewStateLiveData.value as? ContentOverviewUiState.Content ?: return + if (!content.hasNext || content.isLoadingMore) return + + val generation = requestGeneration + _overviewStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null) + requestContents(content.type, content.page + 1, generation) { response -> + val current = _overviewStateLiveData.value as? ContentOverviewUiState.Content ?: content + val data = response.data + if (response.success && data != null) { + val mapped = data.toContent() + _overviewStateLiveData.value = current.copy( + type = mapped.type, + totalCount = mapped.totalCount, + items = current.items + mapped.items, + page = mapped.page, + size = mapped.size, + hasNext = mapped.hasNext, + isLoadingMore = false, + paginationErrorMessage = null + ) + } else { + _overviewStateLiveData.value = current.copy( + isLoadingMore = false, + paginationErrorMessage = response.message + ) + } + } + } + + fun retry() { + loadFirstPage(selectedType) + } + + fun consumePaginationErrorMessage() { + val content = _overviewStateLiveData.value as? ContentOverviewUiState.Content ?: return + if (content.paginationErrorMessage == null) return + + _overviewStateLiveData.value = content.copy(paginationErrorMessage = null) + } + + private fun requestContents( + type: ContentOverviewType, + page: Int, + generation: Int, + onSuccess: (ApiResponse) -> Unit + ) { + compositeDisposable.add( + repository.getContents(authToken(), page, DEFAULT_PAGE_SIZE, type) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + if (generation == requestGeneration) { + onSuccess(it) + } + }, + { + if (generation != requestGeneration) return@subscribe + + it.message?.let { message -> Logger.e(message) } + _isLoading.value = false + val current = _overviewStateLiveData.value as? ContentOverviewUiState.Content + if (current != null && page > FIRST_PAGE) { + _overviewStateLiveData.value = current.copy( + isLoadingMore = false, + paginationErrorMessage = it.message + ) + } else { + showFirstPageError(type, it.message) + } + } + ) + ) + } + + private fun showFirstPageError(type: ContentOverviewType, message: String?) { + _overviewStateLiveData.value = ContentOverviewUiState.Error(type, message) + _toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown) + } + + private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}" + + companion object { + const val DEFAULT_PAGE_SIZE = 20 + private const val FIRST_PAGE = 0 + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewViewModelTest.kt new file mode 100644 index 00000000..df610001 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/overview/ContentOverviewViewModelTest.kt @@ -0,0 +1,293 @@ +package kr.co.vividnext.sodalive.v2.main.content.overview + +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.main.content.overview.data.ContentOverviewItemResponse +import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewPageResponse +import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewRepository +import kr.co.vividnext.sodalive.v2.main.content.overview.data.ContentOverviewType +import kr.co.vividnext.sodalive.v2.main.content.overview.model.ContentOverviewUiState +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.mockito.kotlin.never +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 ContentOverviewViewModelTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private lateinit var repository: ContentOverviewRepository + private lateinit var viewModel: ContentOverviewViewModel + + @Before + fun setUp() { + setImmediateRxSchedulers() + SharedPreferenceManager.resetForTest() + SharedPreferenceManager.init(context) + SharedPreferenceManager.token = "test-token" + repository = org.mockito.kotlin.mock() + viewModel = ContentOverviewViewModel(repository) + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + SharedPreferenceManager.resetForTest() + } + + @Test + fun `초기 로드는 전달받은 type으로 첫 페이지를 요청하고 Content를 emit한다`() { + stubGetContents( + type = ContentOverviewType.FIRST_AUDIO_CONTENT, + response = Single.just( + ApiResponse( + true, + pageResponse( + type = ContentOverviewType.FIRST_AUDIO_CONTENT, + items = listOf(item(11L)) + ), + null + ) + ) + ) + + viewModel.loadFirstPage(ContentOverviewType.FIRST_AUDIO_CONTENT) + + val state = viewModel.overviewStateLiveData.requireValue() as ContentOverviewUiState.Content + assertEquals(ContentOverviewType.FIRST_AUDIO_CONTENT, state.type) + assertEquals(0, state.page) + assertEquals(20, state.size) + assertEquals(listOf(11L), state.items.map { it.contentId }) + verifyGetContents(type = ContentOverviewType.FIRST_AUDIO_CONTENT, page = 0) + } + + @Test + fun `첫 페이지 성공이지만 items가 비어 있으면 Empty를 emit한다`() { + stubGetContents( + response = Single.just( + ApiResponse( + true, + pageResponse(items = emptyList()), + null + ) + ) + ) + + viewModel.loadFirstPage(ContentOverviewType.NEW_AND_HOT_AUDIO) + + val state = viewModel.overviewStateLiveData.requireValue() as ContentOverviewUiState.Empty + assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, state.type) + assertEquals(0, state.totalCount) + } + + @Test + fun `첫 페이지 실패는 Error와 toast를 emit한다`() { + stubGetContents( + response = Single.just(ApiResponse(false, null, "failed")) + ) + + viewModel.loadFirstPage(ContentOverviewType.NEW_AND_HOT_AUDIO) + + val state = viewModel.overviewStateLiveData.requireValue() as ContentOverviewUiState.Error + assertEquals("failed", state.message) + assertEquals(false, viewModel.isLoading.requireValue()) + assertEquals( + kr.co.vividnext.sodalive.R.string.common_error_unknown, + viewModel.toastLiveData.requireValue()?.resId + ) + } + + @Test + fun `hasNext true이면 loadMore는 다음 page를 요청하고 append한다`() { + stubGetContents( + response = Single.just( + ApiResponse( + true, + pageResponse(items = listOf(item(1L)), hasNext = true), + null + ) + ) + ) + stubGetContents( + page = 1, + response = Single.just( + ApiResponse( + true, + pageResponse(page = 1, items = listOf(item(2L)), hasNext = false), + null + ) + ) + ) + + viewModel.loadFirstPage(ContentOverviewType.NEW_AND_HOT_AUDIO) + viewModel.loadMore() + + val state = viewModel.overviewStateLiveData.requireValue() as ContentOverviewUiState.Content + assertEquals(listOf(1L, 2L), state.items.map { it.contentId }) + assertEquals(1, state.page) + assertFalse(state.hasNext) + verifyGetContents(page = 1) + } + + @Test + fun `hasNext false이면 loadMore는 추가 요청하지 않는다`() { + stubGetContents( + response = Single.just( + ApiResponse( + true, + pageResponse(items = listOf(item(1L)), hasNext = false), + null + ) + ) + ) + + viewModel.loadFirstPage(ContentOverviewType.NEW_AND_HOT_AUDIO) + viewModel.loadMore() + + verify( + repository, + never() + ).getContents( + "Bearer test-token", + 1, + ContentOverviewViewModel.DEFAULT_PAGE_SIZE, + ContentOverviewType.NEW_AND_HOT_AUDIO + ) + } + + @Test + fun `추가 페이지 실패 시 기존 items를 유지하고 pagination error를 emit한다`() { + stubGetContents( + response = Single.just( + ApiResponse( + true, + pageResponse(items = listOf(item(1L)), hasNext = true), + null + ) + ) + ) + stubGetContents( + page = 1, + response = Single.just(ApiResponse(false, null, "page failed")) + ) + + viewModel.loadFirstPage(ContentOverviewType.NEW_AND_HOT_AUDIO) + viewModel.loadMore() + + val state = viewModel.overviewStateLiveData.requireValue() as ContentOverviewUiState.Content + assertEquals(listOf(1L), state.items.map { it.contentId }) + assertFalse(state.isLoadingMore) + assertEquals("page failed", state.paginationErrorMessage) + } + + @Test + fun `새 type 요청 이후 이전 stale response는 현재 상태를 덮어쓰지 않는다`() { + val pending = SingleSubject.create>() + stubGetContents(type = ContentOverviewType.NEW_AND_HOT_AUDIO, response = pending) + stubGetContents( + type = ContentOverviewType.FIRST_AUDIO_CONTENT, + response = Single.just( + ApiResponse( + true, + pageResponse( + type = ContentOverviewType.FIRST_AUDIO_CONTENT, + items = listOf(item(9L)) + ), + null + ) + ) + ) + + viewModel.loadFirstPage(ContentOverviewType.NEW_AND_HOT_AUDIO) + viewModel.loadFirstPage(ContentOverviewType.FIRST_AUDIO_CONTENT) + pending.onSuccess( + ApiResponse( + true, + pageResponse(items = listOf(item(1L))), + null + ) + ) + + val state = viewModel.overviewStateLiveData.requireValue() as ContentOverviewUiState.Content + assertEquals(ContentOverviewType.FIRST_AUDIO_CONTENT, state.type) + assertEquals(listOf(9L), state.items.map { it.contentId }) + } + + private fun stubGetContents( + type: ContentOverviewType = ContentOverviewType.NEW_AND_HOT_AUDIO, + page: Int = 0, + size: Int = ContentOverviewViewModel.DEFAULT_PAGE_SIZE, + response: Single> + ) { + whenever(repository.getContents("Bearer test-token", page, size, type)).thenReturn(response) + } + + private fun verifyGetContents( + type: ContentOverviewType = ContentOverviewType.NEW_AND_HOT_AUDIO, + page: Int = 0, + size: Int = ContentOverviewViewModel.DEFAULT_PAGE_SIZE + ) { + verify(repository).getContents("Bearer test-token", page, size, type) + } + + private fun pageResponse( + type: ContentOverviewType = ContentOverviewType.NEW_AND_HOT_AUDIO, + items: List = listOf(item()), + page: Int = 0, + size: Int = 20, + hasNext: Boolean = false + ) = ContentOverviewPageResponse( + type = type, + items = items, + page = page, + size = size, + hasNext = hasNext + ) + + private fun item(contentId: Long = 1L) = ContentOverviewItemResponse( + contentId = contentId, + title = "content $contentId", + coverImage = "https://example.com/$contentId.png", + price = 100, + isAdult = false, + isPointAvailable = false, + isFirstContent = false, + isOriginalSeries = false, + creatorNickname = "creator" + ) + + private fun setImmediateRxSchedulers() { + val trampoline = { _: Scheduler -> Schedulers.trampoline() } + RxJavaPlugins.setIoSchedulerHandler(trampoline) + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() } + } + + private fun LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { value = it } + observeForever(observer) + removeObserver(observer) + return value + } +}