diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingFragmentSourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingFragmentSourceTest.kt index 53736985..ca9780f1 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingFragmentSourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingFragmentSourceTest.kt @@ -153,7 +153,7 @@ class HomeFollowingFragmentSourceTest { ) assertFalse(fragment.contains("binding.viewHomeFollowingRecentNewsTitle.ivSectionTitleChevron")) assertTrue(recentNewsSection.contains("view_home_following_recent_news_title")) - assertTrue(adapter.contains("FollowingNewsType.COMMUNITY_POST -> VIEW_TYPE_COMMUNITY")) + assertTrue(adapter.contains("is HomeFollowingNewsUiItem.Community -> VIEW_TYPE_COMMUNITY")) assertTrue(adapter.contains("is HomeFollowingNewsUiItem.Ranking -> VIEW_TYPE_RANKING")) assertTrue(adapter.contains("R.layout.view_feed_community")) assertTrue(adapter.contains("R.layout.view_feed_rank")) @@ -174,7 +174,7 @@ class HomeFollowingFragmentSourceTest { "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeFollowingNewsAdapter.kt" ).readText() - assertTrue(adapter.contains("ranking.creatorNickname")) + assertTrue(adapter.contains("creatorNickname")) assertTrue(adapter.contains("ranking.rank")) assertTrue(adapter.contains("R.string.screen_home_following_creator_ranking_news_message")) assertTrue(adapter.contains("context.getString(")) @@ -186,6 +186,59 @@ class HomeFollowingFragmentSourceTest { assertFalse(adapter.contains("highlightRanges = emptyList()")) } + @Test + fun `following recent news models use nested payload response contract`() { + val models = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/data/HomeFollowingModels.kt" + ).readText() + val newsResponse = models.substringAfter("data class FollowingNewsResponse(") + .substringBefore(")\n\n@Keep") + + assertTrue(newsResponse.contains("newsId")) + assertTrue(newsResponse.contains("type")) + assertTrue(newsResponse.contains("visibleFromAtUtc")) + assertTrue(newsResponse.contains("creatorRanking")) + assertTrue(newsResponse.contains("audioContent")) + assertTrue(newsResponse.contains("photoContent")) + assertTrue(newsResponse.contains("contentRanking")) + assertTrue(newsResponse.contains("communityPost")) + assertFalse(newsResponse.contains("creatorProfileImageUrl")) + assertFalse(newsResponse.contains("creatorNickname")) + assertFalse(newsResponse.contains("title")) + assertFalse(newsResponse.contains("body")) + assertFalse(newsResponse.contains("thumbnailImageUrl")) + assertFalse(newsResponse.contains("targetId")) + assertFalse(newsResponse.contains("occurredAtUtc")) + assertFalse(newsResponse.contains("rank")) + assertTrue(models.contains("data class FollowingCreatorRankingNewsResponse")) + assertTrue(models.contains("data class FollowingContentNewsResponse")) + assertTrue(models.contains("data class FollowingContentRankingNewsResponse")) + assertTrue(models.contains("data class FollowingCommunityPostNewsResponse")) + } + + @Test + fun `following recent news adapter binds nested payload ui variants`() { + val adapter = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeFollowingNewsAdapter.kt" + ).readText() + + assertTrue(adapter.contains("FollowingNewsType.CONTENT_RANKING")) + assertTrue(adapter.contains("R.string.screen_home_following_content_ranking_news_message")) + assertTrue(adapter.contains("ranking.imageUrl")) + assertTrue(adapter.contains("community.postId.toString()")) + assertTrue(adapter.contains("community.likeCount")) + assertTrue(adapter.contains("community.commentCount")) + assertTrue(adapter.contains("community.content")) + assertTrue(adapter.contains("community.imageUrl")) + assertTrue(adapter.contains("content.contentId.toString()")) + assertTrue(adapter.contains("content.contentImageUrl")) + assertTrue(adapter.contains("content.creatorProfileImageUrl")) + assertFalse(adapter.contains("commentCount = 0")) + assertFalse(adapter.contains("likeCount = 0")) + assertFalse(adapter.contains("community.body.ifBlank { community.title }")) + assertFalse(adapter.contains("ranking.thumbnailImageUrl.orEmpty()")) + } + @Test fun `following creators section has no header and uses figma simple profile size`() { val layout = homeMainLayoutSource() diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingMapperTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingMapperTest.kt index 4f748079..005a57e8 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingMapperTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeFollowingMapperTest.kt @@ -4,7 +4,11 @@ import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter import kr.co.vividnext.sodalive.v2.common.CreatorActivityType import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomListItemResponse +import kr.co.vividnext.sodalive.v2.main.home.data.FollowingCommunityPostNewsResponse +import kr.co.vividnext.sodalive.v2.main.home.data.FollowingContentNewsResponse +import kr.co.vividnext.sodalive.v2.main.home.data.FollowingContentRankingNewsResponse import kr.co.vividnext.sodalive.v2.main.home.data.FollowingCreatorResponse +import kr.co.vividnext.sodalive.v2.main.home.data.FollowingCreatorRankingNewsResponse import kr.co.vividnext.sodalive.v2.main.home.data.FollowingLiveResponse import kr.co.vividnext.sodalive.v2.main.home.data.FollowingNewsResponse import kr.co.vividnext.sodalive.v2.main.home.data.FollowingNewsType @@ -14,6 +18,7 @@ import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingNewsUiItem import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingUiState import kr.co.vividnext.sodalive.v2.main.home.model.toUiState import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -42,7 +47,7 @@ class HomeFollowingMapperTest { onAirLives = listOf(live()), recentChats = listOf(chat()), monthlySchedules = listOf(schedule(scheduleId = "schedule-1")), - recentNews = listOf(news(newsId = "news-1", type = FollowingNewsType.PHOTO_CONTENT)) + recentNews = listOf(photoContentNews(newsId = "news-1")) ).toUiState(formatter) as HomeFollowingUiState.Content assertEquals(1L, state.followingCreators.items.single().creatorId) @@ -73,7 +78,7 @@ class HomeFollowingMapperTest { @Test fun `PHOTO_CONTENT는 photo content label로 매핑된다`() { val state = response( - recentNews = listOf(news(type = FollowingNewsType.PHOTO_CONTENT)) + recentNews = listOf(photoContentNews()) ).toUiState(formatter) as HomeFollowingUiState.Content val item = state.recentNews.items.single() as HomeFollowingNewsUiItem.Content @@ -81,21 +86,96 @@ class HomeFollowingMapperTest { } @Test - fun `ranking news의 rank null 항목은 제외된다`() { + fun `recentNews nested payload는 type별 UI item으로 매핑된다`() { val state = response( recentNews = listOf( - news(newsId = "hidden", type = FollowingNewsType.CREATOR_RANKING, rank = null), - news(newsId = "shown", type = FollowingNewsType.CONTENT_RANKING, rank = 1) + creatorRankingNews(newsId = "creator-ranking"), + contentRankingNews(newsId = "content-ranking"), + audioContentNews(newsId = "audio-content"), + photoContentNews(newsId = "photo-content"), + communityPostNews(newsId = "community-post") + ) + ).toUiState(formatter) as HomeFollowingUiState.Content + + val creatorRanking = state.recentNews.items[0] as HomeFollowingNewsUiItem.Ranking + assertEquals(FollowingNewsType.CREATOR_RANKING, creatorRanking.type) + assertEquals(7, creatorRanking.rank) + assertEquals(1001L, creatorRanking.targetId) + assertEquals("creator nickname", creatorRanking.creatorNickname) + assertEquals("https://example.com/creator-ranking.png", creatorRanking.imageUrl) + + val contentRanking = state.recentNews.items[1] as HomeFollowingNewsUiItem.Ranking + assertEquals(FollowingNewsType.CONTENT_RANKING, contentRanking.type) + assertEquals(3, contentRanking.rank) + assertEquals(2001L, contentRanking.targetId) + assertEquals("ranked content", contentRanking.title) + assertEquals("https://example.com/content-ranking.png", contentRanking.imageUrl) + + val audioContent = state.recentNews.items[2] as HomeFollowingNewsUiItem.Content + assertEquals(FollowingNewsType.AUDIO_CONTENT, audioContent.type) + assertEquals(3001L, audioContent.contentId) + assertEquals("audio title", audioContent.title) + assertEquals("https://example.com/audio.png", audioContent.contentImageUrl) + assertEquals("audio creator", audioContent.creatorNickname) + + val photoContent = state.recentNews.items[3] as HomeFollowingNewsUiItem.Content + assertEquals(FollowingNewsType.PHOTO_CONTENT, photoContent.type) + assertEquals(3002L, photoContent.contentId) + assertEquals(R.string.screen_home_following_photo_content, photoContent.labelResId) + + val community = state.recentNews.items[4] as HomeFollowingNewsUiItem.Community + assertEquals(4001L, community.postId) + assertEquals("community creator", community.creatorNickname) + assertEquals("https://example.com/community-image.png", community.imageUrl) + assertEquals("community body", community.content) + assertEquals("2026-06-25T02:00:00Z", community.createdAt) + assertEquals(11, community.likeCount) + assertEquals(5, community.commentCount) + } + + @Test + fun `type에 대응하는 nested payload가 null이면 recentNews에서 제외된다`() { + val state = response( + recentNews = listOf( + FollowingNewsResponse( + newsId = "missing-creator-ranking", + type = FollowingNewsType.CREATOR_RANKING, + visibleFromAtUtc = "2026-06-25T01:00:00Z", + creatorRanking = null + ), + FollowingNewsResponse( + newsId = "missing-audio-content", + type = FollowingNewsType.AUDIO_CONTENT, + visibleFromAtUtc = "2026-06-25T01:00:00Z", + audioContent = null + ), + contentRankingNews(newsId = "shown") ) ).toUiState(formatter) as HomeFollowingUiState.Content assertEquals(listOf("shown"), state.recentNews.items.map { it.newsId }) } + @Test + fun `contentImageUrl이 null이어도 최근 소식 content와 ranking item은 유지된다`() { + val state = response( + recentNews = listOf( + contentRankingNews(newsId = "content-ranking", contentImageUrl = null), + audioContentNews(newsId = "audio-content", contentImageUrl = null) + ) + ).toUiState(formatter) as HomeFollowingUiState.Content + + val ranking = state.recentNews.items[0] as HomeFollowingNewsUiItem.Ranking + assertNull(ranking.imageUrl) + + val content = state.recentNews.items[1] as HomeFollowingNewsUiItem.Content + assertNull(content.contentImageUrl) + } + @Test fun `news 상대 시간은 visibleFromAtUtc 값을 formatter에 전달한다`() { val state = response( - recentNews = listOf(news(visibleFromAtUtc = "2026-06-25T00:00:00Z")) + recentNews = listOf(audioContentNews(visibleFromAtUtc = "2026-06-25T00:00:00Z")) ).toUiState(formatter) as HomeFollowingUiState.Content assertEquals("relative:2026-06-25T00:00:00Z", state.recentNews.items.single().visibleFromText) @@ -152,22 +232,86 @@ class HomeFollowingMapperTest { isOnAir = false ) - private fun news( + private fun creatorRankingNews( newsId: String = "news", - type: FollowingNewsType = FollowingNewsType.AUDIO_CONTENT, - visibleFromAtUtc: String = "2026-06-25T01:00:00Z", - rank: Int? = null + visibleFromAtUtc: String = "2026-06-25T01:00:00Z" ) = FollowingNewsResponse( newsId = newsId, - type = type, - creatorProfileImageUrl = "https://example.com/news.png", - creatorNickname = "creator", - title = "title", - body = "body", - thumbnailImageUrl = null, - targetId = 6L, - occurredAtUtc = "2026-06-25T00:00:00Z", + type = FollowingNewsType.CREATOR_RANKING, visibleFromAtUtc = visibleFromAtUtc, - rank = rank + creatorRanking = FollowingCreatorRankingNewsResponse( + rank = 7, + creatorId = 1001L, + nickname = "creator nickname", + profileImageUrl = "https://example.com/creator-ranking.png" + ) + ) + + private fun contentRankingNews( + newsId: String = "news", + visibleFromAtUtc: String = "2026-06-25T01:00:00Z", + contentImageUrl: String? = "https://example.com/content-ranking.png" + ) = FollowingNewsResponse( + newsId = newsId, + type = FollowingNewsType.CONTENT_RANKING, + visibleFromAtUtc = visibleFromAtUtc, + contentRanking = FollowingContentRankingNewsResponse( + rank = 3, + contentId = 2001L, + contentImageUrl = contentImageUrl, + title = "ranked content" + ) + ) + + private fun audioContentNews( + newsId: String = "news", + visibleFromAtUtc: String = "2026-06-25T01:00:00Z", + contentImageUrl: String? = "https://example.com/audio.png" + ) = FollowingNewsResponse( + newsId = newsId, + type = FollowingNewsType.AUDIO_CONTENT, + visibleFromAtUtc = visibleFromAtUtc, + audioContent = FollowingContentNewsResponse( + contentId = 3001L, + contentImageUrl = contentImageUrl, + title = "audio title", + creatorProfileImageUrl = "https://example.com/audio-creator.png", + creatorNickname = "audio creator" + ) + ) + + private fun photoContentNews( + newsId: String = "news", + visibleFromAtUtc: String = "2026-06-25T01:00:00Z" + ) = FollowingNewsResponse( + newsId = newsId, + type = FollowingNewsType.PHOTO_CONTENT, + visibleFromAtUtc = visibleFromAtUtc, + photoContent = FollowingContentNewsResponse( + contentId = 3002L, + contentImageUrl = "https://example.com/photo.png", + title = "photo title", + creatorProfileImageUrl = "https://example.com/photo-creator.png", + creatorNickname = "photo creator" + ) + ) + + private fun communityPostNews( + newsId: String = "news", + visibleFromAtUtc: String = "2026-06-25T01:00:00Z" + ) = FollowingNewsResponse( + newsId = newsId, + type = FollowingNewsType.COMMUNITY_POST, + visibleFromAtUtc = visibleFromAtUtc, + communityPost = FollowingCommunityPostNewsResponse( + postId = 4001L, + creatorProfileImage = "https://example.com/community-creator.png", + creatorNickname = "community creator", + imageUrl = "https://example.com/community-image.png", + content = "community body", + createdAt = "2026-06-25T02:00:00Z", + likeCount = 11, + commentCount = 5 + ) ) }