fix(creator): 오디오 탭 theme 선택 정규화를 보정한다

This commit is contained in:
2026-06-19 17:39:41 +09:00
parent 845b36828b
commit c82513eaf0
2 changed files with 73 additions and 44 deletions

View File

@@ -10,7 +10,13 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.common.data.ContentSort import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.data.CreatorChannelAudioTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.audio.data.CreatorChannelAudioTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.CreatorChannelAudioContentUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.CreatorChannelAudioRateUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.CreatorChannelAudioThemeUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.effectiveSelectedThemeId
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.toAudioContentUiModels
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.toRateUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.toThemeUiModels
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
class CreatorChannelAudioViewModel( class CreatorChannelAudioViewModel(
@@ -74,7 +80,7 @@ class CreatorChannelAudioViewModel(
val current = _audioStateLiveData.value as? CreatorChannelAudioUiState.Content ?: content val current = _audioStateLiveData.value as? CreatorChannelAudioUiState.Content ?: content
if (response.success && data != null) { if (response.success && data != null) {
_audioStateLiveData.value = current.copy( _audioStateLiveData.value = current.copy(
audioContents = current.audioContents + data.displayableAudioContents(), audioContents = current.audioContents + data.audioContents.toAudioContentUiModels(),
page = data.page, page = data.page,
size = data.size, size = data.size,
hasNext = data.hasNext, hasNext = data.hasNext,
@@ -102,7 +108,8 @@ class CreatorChannelAudioViewModel(
requestAudio(page = FIRST_PAGE, sort = sort, themeId = themeId, generation = generation) { response -> requestAudio(page = FIRST_PAGE, sort = sort, themeId = themeId, generation = generation) { response ->
val data = response.data val data = response.data
if (response.success && data != null) { if (response.success && data != null) {
val audioContents = data.displayableAudioContents() selectedThemeId = data.effectiveSelectedThemeId()
val audioContents = data.audioContents.toAudioContentUiModels()
_audioStateLiveData.value = if (audioContents.isEmpty() || data.audioContentCount == 0) { _audioStateLiveData.value = if (audioContents.isEmpty() || data.audioContentCount == 0) {
CreatorChannelAudioUiState.Empty CreatorChannelAudioUiState.Empty
} else { } else {
@@ -153,18 +160,15 @@ class CreatorChannelAudioViewModel(
) )
} }
private fun CreatorChannelAudioTabResponse.displayableAudioContents(): List<CreatorChannelAudioContentResponse> =
audioContents.filter { it.duration != null }
private fun CreatorChannelAudioTabResponse.toContentState( private fun CreatorChannelAudioTabResponse.toContentState(
audioContents: List<CreatorChannelAudioContentResponse>, audioContents: List<CreatorChannelAudioContentUiModel>,
isLoadingMore: Boolean = false isLoadingMore: Boolean = false
) = CreatorChannelAudioUiState.Content( ) = CreatorChannelAudioUiState.Content(
audioContentCount = audioContentCount, audioContentCount = audioContentCount,
themes = toThemeUiModels(), themes = toThemeUiModels(),
selectedSort = sort, selectedSort = sort,
selectedThemeId = themeId, selectedThemeId = effectiveSelectedThemeId(),
rate = toRateUiModel(), rate = toRateUiModel(isOwner),
audioContents = audioContents, audioContents = audioContents,
page = page, page = page,
size = size, size = size,
@@ -172,33 +176,11 @@ class CreatorChannelAudioViewModel(
isLoadingMore = isLoadingMore isLoadingMore = isLoadingMore
) )
private fun CreatorChannelAudioTabResponse.toThemeUiModels(): List<CreatorChannelAudioThemeUiModel> =
listOf(CreatorChannelAudioThemeUiModel(themeId = null, title = ALL_THEME_TITLE, isSelected = themeId == null)) +
themes.map { theme ->
CreatorChannelAudioThemeUiModel(
themeId = theme.themeId,
title = theme.themeName,
isSelected = theme.themeId == themeId
)
}
private fun CreatorChannelAudioTabResponse.toRateUiModel(): CreatorChannelAudioRateUiModel? =
if (!isOwner && themeId == null) {
CreatorChannelAudioRateUiModel(
ratePercent = purchasedAudioContentRate,
purchasedCount = purchasedAudioContentCount,
paidCount = paidAudioContentCount
)
} else {
null
}
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}" private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
companion object { companion object {
val DEFAULT_PAGE_SIZE = 20 val DEFAULT_PAGE_SIZE = 20
private const val FIRST_PAGE = 0 private const val FIRST_PAGE = 0
private const val ALL_THEME_TITLE = "전체"
} }
} }
@@ -212,7 +194,7 @@ sealed interface CreatorChannelAudioUiState {
val selectedSort: ContentSort, val selectedSort: ContentSort,
val selectedThemeId: Long?, val selectedThemeId: Long?,
val rate: CreatorChannelAudioRateUiModel?, val rate: CreatorChannelAudioRateUiModel?,
val audioContents: List<CreatorChannelAudioContentResponse>, val audioContents: List<CreatorChannelAudioContentUiModel>,
val page: Int, val page: Int,
val size: Int, val size: Int,
val hasNext: Boolean, val hasNext: Boolean,
@@ -220,15 +202,3 @@ sealed interface CreatorChannelAudioUiState {
val paginationErrorMessage: String? = null val paginationErrorMessage: String? = null
) : CreatorChannelAudioUiState ) : CreatorChannelAudioUiState
} }
data class CreatorChannelAudioThemeUiModel(
val themeId: Long?,
val title: String,
val isSelected: Boolean
)
data class CreatorChannelAudioRateUiModel(
val ratePercent: Double,
val purchasedCount: Int,
val paidCount: Int
)

View File

@@ -15,6 +15,7 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.common.data.ContentSort import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.data.CreatorChannelAudioTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.audio.data.CreatorChannelAudioTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.audio.data.CreatorChannelAudioThemeResponse import kr.co.vividnext.sodalive.v2.creator.channel.audio.data.CreatorChannelAudioThemeResponse
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.CreatorChannelAudioRateUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import org.junit.After import org.junit.After
@@ -151,6 +152,64 @@ class CreatorChannelAudioViewModelTest {
verifyGetAudio(sort = ContentSort.POPULAR, themeId = 10L) verifyGetAudio(sort = ContentSort.POPULAR, themeId = 10L)
} }
@Test
fun `응답 themeId가 서버 themes에 없으면 selectedThemeId를 null로 정규화하고 후속 정렬 요청도 null로 보낸다`() {
stubGetAudio(response = Single.just(ApiResponse(true, audioResponse(themeId = 99L), null)))
stubGetAudio(
sort = ContentSort.POPULAR,
themeId = null,
response = Single.just(ApiResponse(true, audioResponse(sort = ContentSort.POPULAR, themeId = null), null))
)
viewModel.loadAudio(100L, isOwner = false)
val initialState = viewModel.audioStateLiveData.requireValue() as CreatorChannelAudioUiState.Content
assertNull(initialState.selectedThemeId)
assertEquals(CreatorChannelAudioRateUiModel(75.0, 3, 4), initialState.rate)
viewModel.changeSort(ContentSort.POPULAR)
val sortedState = viewModel.audioStateLiveData.requireValue() as CreatorChannelAudioUiState.Content
assertNull(sortedState.selectedThemeId)
verifyGetAudio(sort = ContentSort.POPULAR, themeId = null)
}
@Test
fun `선택 테마 응답 themeId가 서버 themes에 없으면 내부 selectedThemeId도 null로 정규화한다`() {
stubGetAudio(response = Single.just(ApiResponse(true, audioResponse(themeId = null), null)))
stubGetAudio(
themeId = 10L,
response = Single.just(ApiResponse(true, audioResponse(themeId = 99L), null))
)
stubGetAudio(
sort = ContentSort.POPULAR,
themeId = null,
response = Single.just(ApiResponse(true, audioResponse(sort = ContentSort.POPULAR, themeId = null), null))
)
whenever(
repository.getAudio(
100L,
0,
CreatorChannelAudioViewModel.DEFAULT_PAGE_SIZE,
ContentSort.POPULAR,
10L,
"Bearer test-token"
)
).thenThrow(AssertionError("정규화 후 정렬 요청은 이전 선택 themeId를 사용하면 안 된다"))
viewModel.loadAudio(100L, isOwner = false)
viewModel.changeTheme(10L)
val themeState = viewModel.audioStateLiveData.requireValue() as CreatorChannelAudioUiState.Content
assertNull(themeState.selectedThemeId)
viewModel.changeSort(ContentSort.POPULAR)
val sortedState = viewModel.audioStateLiveData.requireValue() as CreatorChannelAudioUiState.Content
assertNull(sortedState.selectedThemeId)
verifyGetAudio(themeId = 10L)
verifyGetAudio(sort = ContentSort.POPULAR, themeId = null)
}
@Test @Test
fun `같은 정렬 또는 같은 테마를 다시 선택하면 API를 재호출하지 않는다`() { fun `같은 정렬 또는 같은 테마를 다시 선택하면 API를 재호출하지 않는다`() {
stubGetAudio(response = Single.just(ApiResponse(true, audioResponse(), null))) stubGetAudio(response = Single.just(ApiResponse(true, audioResponse(), null)))