From 845b36828bb0df67fdeafe9c31443a9614ca076e Mon Sep 17 00:00:00 2001 From: klaus Date: Fri, 19 Jun 2026 17:39:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EC=98=A4=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=ED=83=AD=20mapper=EB=A5=BC=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 --- .../audio/model/CreatorChannelAudioMappers.kt | 75 ++++++++ .../model/CreatorChannelAudioUiModels.kt | 33 ++++ .../audio/CreatorChannelAudioMapperTest.kt | 169 ++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/model/CreatorChannelAudioMappers.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/model/CreatorChannelAudioUiModels.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/CreatorChannelAudioMapperTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/model/CreatorChannelAudioMappers.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/model/CreatorChannelAudioMappers.kt new file mode 100644 index 00000000..6af90c5f --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/model/CreatorChannelAudioMappers.kt @@ -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 = + 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.toAudioContentUiModels(): List = + 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 = 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) +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/model/CreatorChannelAudioUiModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/model/CreatorChannelAudioUiModels.kt new file mode 100644 index 00000000..b616beca --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/model/CreatorChannelAudioUiModels.kt @@ -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, + 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 +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/CreatorChannelAudioMapperTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/CreatorChannelAudioMapperTest.kt new file mode 100644 index 00000000..d2c0a020 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/audio/CreatorChannelAudioMapperTest.kt @@ -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 + ) +}