diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsMappers.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsMappers.kt new file mode 100644 index 00000000..3f52de8c --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsMappers.kt @@ -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 = buildSet { + if (isOriginalSeries) add(AudioContentTag.Original) + if (isFirstContent) add(AudioContentTag.First) + if (isPointAvailable) add(AudioContentTag.Point) + if (price == 0) add(AudioContentTag.Free) +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsUiModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsUiModels.kt new file mode 100644 index 00000000..08d608f2 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsUiModels.kt @@ -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 +) + +data class ContentOriginalSeriesSection( + val items: List +) + +data class ContentAudioCardSection( + val items: List +) + +data class ContentCommentedAudioSection( + val items: List +) + +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, + val showAdultBadge: Boolean +) + +data class ContentCommentedAudioUiModel( + val audioContentId: Long, + val title: String, + val imageUrl: String?, + val latestComment: String, + val latestCommentWriterProfileImageUrl: String, + val showLatestComment: Boolean +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsUiState.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsUiState.kt new file mode 100644 index 00000000..dfe67ddb --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/model/AudioRecommendationsUiState.kt @@ -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 +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/AudioRecommendationsMapperTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/AudioRecommendationsMapperTest.kt new file mode 100644 index 00000000..b97e3e46 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/AudioRecommendationsMapperTest.kt @@ -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(), content.latestAudios.items) + assertEquals(emptyList(), 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 = emptyList(), + originalSeries: List = emptyList(), + latestAudios: List = emptyList(), + newAndHotAudios: List = emptyList(), + freeAudios: List = emptyList(), + pointAudios: List = emptyList(), + mostCommentedAudios: List = emptyList(), + recommendedAudios: List = 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 + ) +}