feat(content): 추천 UI 모델 매핑을 추가한다

This commit is contained in:
2026-06-23 15:50:24 +09:00
parent 5746239873
commit c02437797c
4 changed files with 319 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
package kr.co.vividnext.sodalive.v2.main.content.model
import kr.co.vividnext.sodalive.v2.main.content.data.AudioBannerResponse
import kr.co.vividnext.sodalive.v2.main.content.data.AudioCardResponse
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRecommendationsResponse
import kr.co.vividnext.sodalive.v2.main.content.data.CommentedAudioResponse
import kr.co.vividnext.sodalive.v2.main.content.data.OriginalSeriesResponse
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
fun AudioRecommendationsResponse.toContent(): AudioRecommendationsUiState.Content = AudioRecommendationsUiState.Content(
banners = ContentBannerSection(banners.map { it.toUiModel() }),
originalSeries = ContentOriginalSeriesSection(originalSeries.map { it.toUiModel() }),
latestAudios = ContentAudioCardSection(latestAudios.map { it.toUiModel() }),
newAndHotAudios = ContentAudioCardSection(newAndHotAudios.map { it.toUiModel() }),
freeAudios = ContentAudioCardSection(freeAudios.map { it.toUiModel() }),
pointAudios = ContentAudioCardSection(pointAudios.map { it.toUiModel() }),
mostCommentedAudios = ContentCommentedAudioSection(mostCommentedAudios.map { it.toUiModel() }),
recommendedAudios = ContentAudioCardSection(recommendedAudios.map { it.toUiModel() })
)
fun AudioBannerResponse.toUiModel(): ContentBannerUiModel = ContentBannerUiModel(
imageUrl = imageUrl,
eventItem = eventItem,
creatorId = creatorId,
seriesId = seriesId,
link = link
)
fun OriginalSeriesResponse.toUiModel(): ContentOriginalSeriesUiModel = ContentOriginalSeriesUiModel(
seriesId = seriesId,
coverImageUrl = coverImageUrl
)
fun AudioCardResponse.toUiModel(): ContentAudioCardUiModel = ContentAudioCardUiModel(
audioContentId = audioContentId,
title = title,
imageUrl = imageUrl,
price = price,
creatorNickname = creatorNickname,
tags = toAudioContentTags(),
showAdultBadge = isAdult
)
fun CommentedAudioResponse.toUiModel(): ContentCommentedAudioUiModel = ContentCommentedAudioUiModel(
audioContentId = audioContentId,
title = title,
imageUrl = imageUrl,
latestComment = latestComment,
latestCommentWriterProfileImageUrl = latestCommentWriterProfileImageUrl,
showLatestComment = latestComment.isNotBlank()
)
private fun AudioCardResponse.toAudioContentTags(): Set<AudioContentTag> = buildSet {
if (isOriginalSeries) add(AudioContentTag.Original)
if (isFirstContent) add(AudioContentTag.First)
if (isPointAvailable) add(AudioContentTag.Point)
if (price == 0) add(AudioContentTag.Free)
}

View File

@@ -0,0 +1,52 @@
package kr.co.vividnext.sodalive.v2.main.content.model
import kr.co.vividnext.sodalive.settings.event.EventItem
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
data class ContentBannerSection(
val items: List<ContentBannerUiModel>
)
data class ContentOriginalSeriesSection(
val items: List<ContentOriginalSeriesUiModel>
)
data class ContentAudioCardSection(
val items: List<ContentAudioCardUiModel>
)
data class ContentCommentedAudioSection(
val items: List<ContentCommentedAudioUiModel>
)
data class ContentBannerUiModel(
val imageUrl: String,
val eventItem: EventItem?,
val creatorId: Long?,
val seriesId: Long?,
val link: String?
)
data class ContentOriginalSeriesUiModel(
val seriesId: Long,
val coverImageUrl: String?
)
data class ContentAudioCardUiModel(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val price: Int,
val creatorNickname: String,
val tags: Set<AudioContentTag>,
val showAdultBadge: Boolean
)
data class ContentCommentedAudioUiModel(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val latestComment: String,
val latestCommentWriterProfileImageUrl: String,
val showLatestComment: Boolean
)

View File

@@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.v2.main.content.model
sealed interface AudioRecommendationsUiState {
data object Loading : AudioRecommendationsUiState
data class Content(
val banners: ContentBannerSection,
val originalSeries: ContentOriginalSeriesSection,
val latestAudios: ContentAudioCardSection,
val newAndHotAudios: ContentAudioCardSection,
val freeAudios: ContentAudioCardSection,
val pointAudios: ContentAudioCardSection,
val mostCommentedAudios: ContentCommentedAudioSection,
val recommendedAudios: ContentAudioCardSection
) : AudioRecommendationsUiState {
val isEmpty: Boolean = listOf(
banners.items,
originalSeries.items,
latestAudios.items,
newAndHotAudios.items,
freeAudios.items,
pointAudios.items,
mostCommentedAudios.items,
recommendedAudios.items
).all { it.isEmpty() }
}
data object Empty : AudioRecommendationsUiState
data class Error(
val message: String? = null
) : AudioRecommendationsUiState
}

View File

@@ -0,0 +1,176 @@
package kr.co.vividnext.sodalive.v2.main.content
import kr.co.vividnext.sodalive.v2.main.content.data.AudioBannerResponse
import kr.co.vividnext.sodalive.v2.main.content.data.AudioCardResponse
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRecommendationsResponse
import kr.co.vividnext.sodalive.v2.main.content.data.CommentedAudioResponse
import kr.co.vividnext.sodalive.v2.main.content.data.OriginalSeriesResponse
import kr.co.vividnext.sodalive.v2.main.content.model.toContent
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AudioRecommendationsMapperTest {
@Test
fun `price가 0이면 무료 tag가 포함된다`() {
val item = response(latestAudios = listOf(audio(price = 0))).toContent().latestAudios.items.single()
assertTrue(AudioContentTag.Free in item.tags)
}
@Test
fun `포인트 첫 콘텐츠 오리지널 시리즈 flag는 tag로 매핑된다`() {
val item = response(
latestAudios = listOf(
audio(
isPointAvailable = true,
isFirstContent = true,
isOriginalSeries = true
)
)
).toContent().latestAudios.items.single()
assertTrue(AudioContentTag.Point in item.tags)
assertTrue(AudioContentTag.First in item.tags)
assertTrue(AudioContentTag.Original in item.tags)
}
@Test
fun `성인 콘텐츠 flag는 showAdultBadge로 매핑된다`() {
val item = response(latestAudios = listOf(audio(isAdult = true))).toContent().latestAudios.items.single()
assertTrue(item.showAdultBadge)
}
@Test
fun `duration은 공통 오디오 UI model에 노출하지 않는다`() {
val fields = response(latestAudios = listOf(audio(duration = "10:00")))
.toContent()
.latestAudios
.items
.single()
.javaClass
.declaredFields
.map { it.name }
assertFalse(fields.contains("duration"))
}
@Test
fun `빈 리스트 섹션은 content의 empty 판단에 반영된다`() {
val content = response().toContent()
assertTrue(content.isEmpty)
assertEquals(emptyList<Any>(), content.latestAudios.items)
assertEquals(emptyList<Any>(), content.recommendedAudios.items)
}
@Test
fun `latestComment가 blank이면 댓글 영역 표시 flag가 false다`() {
val item = response(mostCommentedAudios = listOf(commentedAudio(latestComment = " ")))
.toContent()
.mostCommentedAudios
.items
.single()
assertFalse(item.showLatestComment)
}
@Test
fun `응답 리스트는 섹션별 UI model로 매핑된다`() {
val content = response(
banners = listOf(banner()),
originalSeries = listOf(originalSeries()),
latestAudios = listOf(audio(audioContentId = 1L)),
newAndHotAudios = listOf(audio(audioContentId = 2L)),
freeAudios = listOf(audio(audioContentId = 3L)),
pointAudios = listOf(audio(audioContentId = 4L)),
mostCommentedAudios = listOf(commentedAudio(audioContentId = 5L)),
recommendedAudios = listOf(audio(audioContentId = 6L))
).toContent()
assertFalse(content.isEmpty)
assertEquals(1, content.banners.items.size)
assertEquals(10L, content.originalSeries.items.single().seriesId)
assertEquals(1L, content.latestAudios.items.single().audioContentId)
assertEquals(2L, content.newAndHotAudios.items.single().audioContentId)
assertEquals(3L, content.freeAudios.items.single().audioContentId)
assertEquals(4L, content.pointAudios.items.single().audioContentId)
assertEquals(5L, content.mostCommentedAudios.items.single().audioContentId)
assertEquals(6L, content.recommendedAudios.items.single().audioContentId)
}
private fun response(
banners: List<AudioBannerResponse> = emptyList(),
originalSeries: List<OriginalSeriesResponse> = emptyList(),
latestAudios: List<AudioCardResponse> = emptyList(),
newAndHotAudios: List<AudioCardResponse> = emptyList(),
freeAudios: List<AudioCardResponse> = emptyList(),
pointAudios: List<AudioCardResponse> = emptyList(),
mostCommentedAudios: List<CommentedAudioResponse> = emptyList(),
recommendedAudios: List<AudioCardResponse> = emptyList()
) = AudioRecommendationsResponse(
banners = banners,
originalSeries = originalSeries,
latestAudios = latestAudios,
newAndHotAudios = newAndHotAudios,
freeAudios = freeAudios,
pointAudios = pointAudios,
mostCommentedAudios = mostCommentedAudios,
recommendedAudios = recommendedAudios
)
private fun banner() = AudioBannerResponse(
imageUrl = "https://example.com/banner.png",
eventItem = null,
creatorId = null,
seriesId = null,
link = null
)
private fun originalSeries() = OriginalSeriesResponse(
seriesId = 10L,
coverImageUrl = "https://example.com/series.png"
)
private fun audio(
audioContentId: Long = 1L,
title: String = "오디오",
duration: String? = "10:00",
imageUrl: String? = "https://example.com/audio.png",
price: Int = 100,
isAdult: Boolean = false,
isPointAvailable: Boolean = false,
isFirstContent: Boolean = false,
isOriginalSeries: Boolean = false,
creatorNickname: String = "크리에이터"
) = AudioCardResponse(
audioContentId = audioContentId,
title = title,
duration = duration,
imageUrl = imageUrl,
price = price,
isAdult = isAdult,
isPointAvailable = isPointAvailable,
isFirstContent = isFirstContent,
isOriginalSeries = isOriginalSeries,
creatorNickname = creatorNickname
)
private fun commentedAudio(
audioContentId: Long = 1L,
title: String = "댓글 오디오",
imageUrl: String? = "https://example.com/commented.png",
latestComment: String = "좋아요",
latestCommentWriterProfileImageUrl: String = "https://example.com/profile.png"
) = CommentedAudioResponse(
audioContentId = audioContentId,
title = title,
imageUrl = imageUrl,
latestComment = latestComment,
latestCommentWriterProfileImageUrl = latestCommentWriterProfileImageUrl
)
}