feat(creator): 오디오 탭 mapper를 추가한다

This commit is contained in:
2026-06-19 17:39:31 +09:00
parent d0843d94ed
commit 845b36828b
3 changed files with 277 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.model
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.widget.AudioContentTag
private const val ALL_THEME_TITLE = "전체"
fun CreatorChannelAudioTabResponse.toThemeUiModels(): List<CreatorChannelAudioThemeUiModel> =
listOf(
CreatorChannelAudioThemeUiModel(
themeId = null,
title = ALL_THEME_TITLE,
isSelected = effectiveSelectedThemeId() == null
)
) +
themes.map { theme ->
CreatorChannelAudioThemeUiModel(
themeId = theme.themeId,
title = theme.themeName,
isSelected = theme.themeId == effectiveSelectedThemeId()
)
}
fun CreatorChannelAudioTabResponse.effectiveSelectedThemeId(): Long? =
themeId?.takeIf { selectedThemeId -> themes.any { it.themeId == selectedThemeId } }
fun CreatorChannelAudioTabResponse.toRateUiModel(isOwner: Boolean): CreatorChannelAudioRateUiModel? =
if (!isOwner && effectiveSelectedThemeId() == null) {
CreatorChannelAudioRateUiModel(
ratePercent = purchasedAudioContentRate,
purchasedCount = purchasedAudioContentCount,
paidCount = paidAudioContentCount
)
} else {
null
}
fun List<CreatorChannelAudioContentResponse>.toAudioContentUiModels(): List<CreatorChannelAudioContentUiModel> =
mapNotNull { it.toAudioContentUiModel() }
private fun CreatorChannelAudioContentResponse.toAudioContentUiModel(): CreatorChannelAudioContentUiModel? {
val duration = duration ?: return null
return CreatorChannelAudioContentUiModel(
audioContentId = audioContentId,
title = title,
secondaryText = secondaryText(duration),
imageUrl = imageUrl,
price = price,
showAdultBadge = isAdult,
tags = toAudioContentTags(),
status = toAudioContentStatus()
)
}
private fun CreatorChannelAudioContentResponse.secondaryText(duration: String): String =
if (seriesName.isNullOrBlank()) {
duration
} else {
"$duration$seriesName"
}
private fun CreatorChannelAudioContentResponse.toAudioContentTags(): Set<AudioContentTag> = buildSet {
if (isOriginalSeries == true) add(AudioContentTag.Original)
if (isFirstContent) add(AudioContentTag.First)
if (isPointAvailable) add(AudioContentTag.Point)
if (price == 0) add(AudioContentTag.Free)
}
private fun CreatorChannelAudioContentResponse.toAudioContentStatus(): CreatorChannelAudioContentStatus = when {
isOwned -> CreatorChannelAudioContentStatus.Owned
isRented -> CreatorChannelAudioContentStatus.Rented
price == 0 -> CreatorChannelAudioContentStatus.Play
else -> CreatorChannelAudioContentStatus.Price(price)
}

View File

@@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.model
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
data class CreatorChannelAudioThemeUiModel(
val themeId: Long?,
val title: String,
val isSelected: Boolean
)
data class CreatorChannelAudioRateUiModel(
val ratePercent: Double,
val purchasedCount: Int,
val paidCount: Int
)
data class CreatorChannelAudioContentUiModel(
val audioContentId: Long,
val title: String,
val secondaryText: String,
val imageUrl: String?,
val price: Int,
val showAdultBadge: Boolean,
val tags: Set<AudioContentTag>,
val status: CreatorChannelAudioContentStatus
)
sealed interface CreatorChannelAudioContentStatus {
data object Play : CreatorChannelAudioContentStatus
data object Owned : CreatorChannelAudioContentStatus
data object Rented : CreatorChannelAudioContentStatus
data class Price(val price: Int) : CreatorChannelAudioContentStatus
}

View File

@@ -0,0 +1,169 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio
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.CreatorChannelAudioThemeResponse
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.CreatorChannelAudioContentStatus
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.CreatorChannelAudioRateUiModel
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.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CreatorChannelAudioMapperTest {
@Test
fun `themes 응답 앞에 전체 tab을 추가하고 themeId null이면 전체를 선택한다`() {
val themes = audioResponse(themeId = null).toThemeUiModels()
assertEquals(listOf(null, 10L, 20L), themes.map { it.themeId })
assertEquals("전체", themes.first().title)
assertTrue(themes.first().isSelected)
}
@Test
fun `응답 themeId가 특정 id이면 해당 서버 theme tab만 selected 상태다`() {
val themes = audioResponse(themeId = 20L).toThemeUiModels()
assertEquals(listOf(false, false, true), themes.map { it.isSelected })
}
@Test
fun `응답 themeId가 서버 themes에 없으면 전체 tab을 selected 상태로 fallback한다`() {
val themes = audioResponse(themeId = 99L).toThemeUiModels()
assertEquals(listOf(true, false, false), themes.map { it.isSelected })
}
@Test
fun `응답 themeId가 서버 themes에 없으면 effective selected themeId는 null이다`() {
val response = audioResponse(themeId = 99L)
assertNull(response.effectiveSelectedThemeId())
}
@Test
fun `seriesName이 있으면 secondary text는 duration bullet seriesName이다`() {
val item = listOf(audioContent(duration = "10:00", seriesName = "시리즈명")).toAudioContentUiModels().single()
assertEquals("10:00 • 시리즈명", item.secondaryText)
}
@Test
fun `seriesName이 null 또는 blank이면 secondary text는 duration만 사용한다`() {
val items = listOf(
audioContent(audioContentId = 1L, duration = "10:00", seriesName = null),
audioContent(audioContentId = 2L, duration = "11:00", seriesName = " ")
).toAudioContentUiModels()
assertEquals(listOf("10:00", "11:00"), items.map { it.secondaryText })
}
@Test
fun `duration null item은 mapper 결과에서 제외한다`() {
val items = listOf(
audioContent(audioContentId = 1L, duration = null),
audioContent(audioContentId = 2L, duration = "10:00")
).toAudioContentUiModels()
assertEquals(listOf(2L), items.map { it.audioContentId })
}
@Test
fun `소장과 대여가 동시에 true이면 소장중 상태를 우선 매핑한다`() {
val item = listOf(audioContent(isOwned = true, isRented = true)).toAudioContentUiModels().single()
assertEquals(CreatorChannelAudioContentStatus.Owned, item.status)
}
@Test
fun `무료 콘텐츠는 무료 tag와 play CTA 상태로 매핑한다`() {
val item = listOf(audioContent(price = 0)).toAudioContentUiModels().single()
assertEquals(CreatorChannelAudioContentStatus.Play, item.status)
assertTrue(AudioContentTag.Free in item.tags)
}
@Test
fun `유료 미보유 콘텐츠는 price 상태로 매핑한다`() {
val item = listOf(audioContent(price = 500)).toAudioContentUiModels().single()
assertEquals(CreatorChannelAudioContentStatus.Price(500), item.status)
}
@Test
fun `성인 포인트 첫 콘텐츠 오리지널 시리즈 tag와 badge는 라이브 item 정책과 동일하게 매핑한다`() {
val item = listOf(
audioContent(
isAdult = true,
isPointAvailable = true,
isFirstContent = true,
isOriginalSeries = true
)
).toAudioContentUiModels().single()
assertTrue(item.showAdultBadge)
assertTrue(AudioContentTag.Point in item.tags)
assertTrue(AudioContentTag.First in item.tags)
assertTrue(AudioContentTag.Original in item.tags)
}
@Test
fun `소장률은 내 채널이 아니고 전체 테마일 때만 생성된다`() {
val response = audioResponse(themeId = null)
assertEquals(CreatorChannelAudioRateUiModel(75.0, 3, 4), response.toRateUiModel(isOwner = false))
assertNull(response.toRateUiModel(isOwner = true))
assertNull(audioResponse(themeId = 10L).toRateUiModel(isOwner = false))
assertEquals(CreatorChannelAudioRateUiModel(75.0, 3, 4), audioResponse(themeId = 99L).toRateUiModel(isOwner = false))
}
private fun audioResponse(themeId: Long?) = CreatorChannelAudioTabResponse(
audioContentCount = 1,
themes = listOf(
CreatorChannelAudioThemeResponse(themeId = 10L, themeName = "ASMR"),
CreatorChannelAudioThemeResponse(themeId = 20L, themeName = "수면")
),
themeId = themeId,
purchasedAudioContentRate = 75.0,
purchasedAudioContentCount = 3,
paidAudioContentCount = 4,
audioContents = listOf(audioContent()),
sort = ContentSort.LATEST,
page = 0,
size = 20,
hasNext = false
)
private fun audioContent(
audioContentId: Long = 1L,
price: Int = 10,
isPointAvailable: Boolean = false,
isFirstContent: Boolean = false,
seriesName: String? = null,
isOriginalSeries: Boolean? = false,
isAdult: Boolean = false,
isOwned: Boolean = false,
isRented: Boolean = false,
duration: String? = "10:00"
) = CreatorChannelAudioContentResponse(
audioContentId = audioContentId,
title = "오디오 $audioContentId",
duration = duration,
imageUrl = "https://example.com/audio.png",
price = price,
isPointAvailable = isPointAvailable,
isFirstContent = isFirstContent,
seriesName = seriesName,
isOriginalSeries = isOriginalSeries,
isAdult = isAdult,
isOwned = isOwned,
isRented = isRented
)
}