test(creator): 채널 홈 화면 계약을 검증한다

This commit is contained in:
2026-06-15 13:21:40 +09:00
parent febd718796
commit 573df4318b

View File

@@ -0,0 +1,596 @@
package kr.co.vividnext.sodalive.v2.creator.channel
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelDonationCardWidthDp
import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelDonationHeaderColorRes
import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelNoticeCardWidthDp
import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelScheduleTimelineLineCount
import kr.co.vividnext.sodalive.v2.creator.channel.ui.formatCreatorChannelScheduleDate
import kr.co.vividnext.sodalive.v2.creator.channel.ui.formatCreatorChannelScheduleDayOfWeek
import kr.co.vividnext.sodalive.v2.creator.channel.ui.formatCreatorChannelScheduleTime
import kr.co.vividnext.sodalive.v2.creator.channel.ui.calculateCreatorChannelSnsButtonSizeDp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import java.util.Locale
import java.util.TimeZone
class CreatorChannelHomeActivitySourceTest {
@Test
fun `Activity source는 intent helper invalid id ViewModel observe navigation 계약을 연결한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt"
).readText()
assertTrue(source.contains("BaseActivity<ActivityCreatorChannelHomeBinding>"))
assertTrue(source.contains("const val EXTRA_CREATOR_ID"))
assertTrue(source.contains("fun newIntent(context: Context, creatorId: Long): Intent"))
assertTrue(source.contains("Intent(context, CreatorChannelHomeActivity::class.java)"))
assertTrue(source.contains("putExtra(EXTRA_CREATOR_ID, creatorId)"))
assertTrue(source.contains("private val viewModel: CreatorChannelHomeViewModel by viewModel()"))
assertTrue(source.contains("if (creatorId <= 0L)"))
assertTrue(source.contains("finish()"))
assertTrue(source.contains("viewModel.loadHome(creatorId)"))
assertTrue(source.contains("viewModel.homeStateLiveData.observe(this)"))
assertTrue(source.contains("viewModel.chatRoomIdLiveData.observe(this)"))
assertFalse(source.contains("is CreatorChannelHomeUiState.Error -> showToast"))
assertTrue(source.contains("event.consume()?.let"))
assertTrue(source.contains("ChatRoomActivity.newIntent(this, chatRoomId)"))
assertTrue(source.contains("DmChatRoomActivity.newIntentByCreatorId(this, creatorId)"))
assertTrue(source.contains("viewModel.createChatRoom(characterId)"))
assertTrue(source.contains("updateActionButtonLayout"))
assertTrue(source.contains("marginStart = if (isChatVisible && isDmVisible)"))
assertFalse(source.contains("CreatorFollowNotifyFragment"))
}
@Test
fun `layout source는 HorizontalScrollView 기반 7개 탭 컨테이너와 RecyclerView를 가진다`() {
val layout = projectFile("app/src/main/res/layout/activity_creator_channel_home.xml").readText()
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt"
).readText()
assertTrue(layout.contains("<HorizontalScrollView"))
assertTrue(layout.contains("@+id/horizontal_tab_scroll_view"))
assertTrue(layout.contains("@+id/tab_container"))
assertTrue(layout.contains("@+id/rv_home_sections"))
assertTrue(layout.contains("android:drawableStart=\"@drawable/ic_new_talk\""))
assertTrue(layout.contains("android:drawableStart=\"@drawable/ic_new_dm\""))
assertFalse(layout.contains("TextTabBarView"))
assertTrue(source.contains("getString(tab.labelResId)"))
}
@Test
fun `title bar source는 Figma 상태별 capsule 구조를 사용한다`() {
val layout = projectFile("app/src/main/res/layout/activity_creator_channel_home.xml").readText()
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt"
).readText()
assertTrue(layout.contains("@+id/layout_follow_capsule"))
assertTrue(layout.contains("@+id/tv_follow_label"))
assertTrue(layout.contains("@drawable/bg_creator_channel_follow_capsule"))
assertTrue(layout.contains("@drawable/bg_creator_channel_following_capsule"))
assertTrue(layout.contains("android:paddingHorizontal=\"@dimen/spacing_8\""))
assertTrue(source.contains("binding.layoutFollowCapsule"))
assertTrue(source.contains("binding.tvFollowLabel.isVisible = !header.isFollow"))
assertFalse(layout.contains("@+id/tv_follow\""))
}
@Test
fun `creator channel home은 status bar 뒤까지 header를 그리고 자체 inset을 처리한다`() {
val baseActivity = projectFile("app/src/main/java/kr/co/vividnext/sodalive/base/BaseActivity.kt").readText()
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt"
).readText()
assertTrue(baseActivity.contains("shouldApplySystemBarTopInset"))
assertTrue(baseActivity.contains("if (shouldApplySystemBarTopInset) systemBars.top else 0"))
assertTrue(source.contains("override val shouldApplySystemBarTopInset: Boolean = false"))
assertTrue(source.contains("setTitleBarTopInset"))
}
@Test
fun `creator channel home은 어두운 header 위 status bar icon을 밝게 표시한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt"
).readText()
assertTrue(source.contains("WindowCompat.getInsetsController(window, binding.root)"))
assertTrue(source.contains("isAppearanceLightStatusBars = false"))
}
@Test
fun `tab source는 Figma 기준 selected indicator와 16sp 고정 폭 탭을 사용한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt"
).readText()
assertTrue(source.contains("createTabView(tab, isSelected = index == 0)"))
assertTrue(source.contains("tabText.textSize = 16f"))
assertTrue(source.contains("width = 110.dpToPx().toInt()"))
assertTrue(source.contains("indicator.setBackgroundColor(getColor(R.color.soda_400))"))
assertTrue(source.contains("indicator.isVisible = isSelected"))
}
@Test
fun `section adapter source는 활동 지표를 행 단위 resource label로 표시한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
assertTrue(adapter.contains("creator_channel_activity_debut"))
assertTrue(adapter.contains("creator_channel_activity_debut_format"))
assertTrue(adapter.contains("creator_channel_activity_live_count_format"))
assertTrue(adapter.contains("creator_channel_activity_live_duration_format"))
assertTrue(adapter.contains("creator_channel_activity_live_contributor_format"))
assertTrue(adapter.contains("creator_channel_activity_audio_count_format"))
assertTrue(adapter.contains("creator_channel_activity_series_count_format"))
assertTrue(adapter.contains("creator_channel_activity_live_count"))
assertTrue(adapter.contains("creator_channel_activity_live_duration"))
assertTrue(adapter.contains("creator_channel_activity_live_contributor"))
assertTrue(adapter.contains("creator_channel_activity_audio_count"))
assertTrue(adapter.contains("creator_channel_activity_series_count"))
assertFalse(adapter.contains("addActivityRow(activity.dDay, activity.debutDateUtc.orEmpty())"))
assertFalse(adapter.contains("\"Live "))
assertFalse(adapter.contains("\"Audio "))
assertFalse(adapter.contains("\"Series "))
}
@Test
fun `section adapter source는 Figma 섹션별 데이터를 공통 카드 하나로 축약하지 않는다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
assertTrue(adapter.contains("bindAudioContents"))
assertTrue(adapter.contains("bindSeries"))
assertTrue(adapter.contains("bindCommunities"))
assertTrue(adapter.contains("bindFanTalk"))
assertTrue(adapter.contains("bindSns"))
assertTrue(adapter.contains("bindActivity"))
assertTrue(adapter.contains("ll_section_items"))
assertTrue(adapter.contains("sectionItems?.addView"))
assertTrue(adapter.contains("addActivityRow"))
assertTrue(adapter.contains("createContentTile"))
assertTrue(adapter.contains("addFeedCard"))
assertFalse(adapter.contains("addScheduleRow"))
assertFalse(adapter.contains("addDonationCard"))
assertTrue(adapter.contains("addCommentCard"))
assertTrue(adapter.contains("createSnsButton"))
assertTrue(adapter.contains("activity.debutDateUtc"))
assertTrue(adapter.contains("activity.liveDurationHours"))
assertTrue(adapter.contains("activity.liveContributorCount"))
assertFalse(adapter.contains("audioContents.joinToString { it.title }"))
assertFalse(adapter.contains("series.joinToString { it.title }"))
assertFalse(adapter.contains("communities.firstOrNull()?.content"))
assertFalse(adapter.contains("joinToString(separator = \"\\n\")"))
assertFalse(adapter.contains("private fun addCard"))
assertFalse(adapter.contains("getChildAt"))
assertFalse(adapter.contains("keepLegacyViewsReferenced"))
assertFalse(adapter.contains("fun CreatorChannelHomeSection.imageUrl"))
}
@Test
fun `현재 라이브 섹션은 전용 layout과 bind로 렌더링한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
val liveLayout = projectFile("app/src/main/res/layout/item_creator_channel_home_live.xml").readText()
assertTrue(liveLayout.contains("@+id/layout_current_live_card"))
assertTrue(liveLayout.contains("@+id/tv_current_live_title"))
assertTrue(liveLayout.contains("@+id/tv_current_live_start_time"))
assertTrue(liveLayout.contains("@+id/tv_current_live_price"))
assertTrue(liveLayout.contains("@+id/tv_current_live_adult"))
assertTrue(liveLayout.contains("@drawable/bg_creator_channel_current_live"))
assertTrue(liveLayout.contains("@drawable/bg_creator_channel_current_live_price"))
assertTrue(liveLayout.contains("@drawable/ic_can"))
assertTrue(liveLayout.contains("android:layout_height=\"78dp\""))
assertFalse(liveLayout.contains("@layout/view_section_title"))
assertFalse(liveLayout.contains("@+id/tv_section_title"))
assertFalse(liveLayout.contains("@+id/ll_section_items"))
assertTrue(adapter.contains("private val currentLiveTitle: TextView?"))
assertTrue(adapter.contains("private val currentLiveStartTime: TextView?"))
assertTrue(adapter.contains("private val currentLivePrice: TextView?"))
assertTrue(adapter.contains("private val currentLiveAdult: TextView?"))
assertTrue(adapter.contains("currentLiveTitle?.text = item.live.title"))
assertTrue(adapter.contains("currentLivePriceLayout?.isVisible = item.live.price > 0"))
assertTrue(adapter.contains("currentLiveAdult?.isVisible = item.live.isAdult"))
assertFalse(adapter.contains("private fun addHeroCard"))
assertFalse(adapter.contains("addHeroCard("))
}
@Test
fun `최신 오디오 섹션은 Figma Contents 변형 전용 layout과 bind로 렌더링한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
val latestAudioLayout = projectFile(
"app/src/main/res/layout/item_creator_channel_home_latest_audio.xml"
).readText()
val thumbnailView = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelLatestAudioThumbnailView.kt"
).readText()
assertTrue(latestAudioLayout.contains("@+id/layout_latest_audio_card"))
assertTrue(latestAudioLayout.contains("CreatorChannelLatestAudioThumbnailView"))
assertTrue(latestAudioLayout.contains("@+id/iv_latest_audio_thumbnail"))
assertTrue(latestAudioLayout.contains("@+id/iv_latest_audio_point_tag"))
assertTrue(latestAudioLayout.contains("@+id/tv_latest_audio_new_label"))
assertTrue(latestAudioLayout.contains("@+id/tv_latest_audio_title"))
assertTrue(latestAudioLayout.contains("@+id/tv_latest_audio_duration"))
assertTrue(latestAudioLayout.contains("@drawable/ic_content_tag_point"))
assertTrue(latestAudioLayout.contains("android:fontFamily=\"@font/pattaya_regular\""))
assertTrue(latestAudioLayout.contains("android:layout_width=\"88dp\""))
assertTrue(latestAudioLayout.contains("android:layout_height=\"88dp\""))
assertFalse(latestAudioLayout.contains("@layout/view_section_title"))
assertFalse(latestAudioLayout.contains("@+id/tv_section_title"))
assertFalse(latestAudioLayout.contains("@+id/ll_section_items"))
assertFalse(latestAudioLayout.contains("AudioContentCardView"))
assertFalse(latestAudioLayout.contains("android:clipToOutline"))
assertTrue(thumbnailView.contains("clipToOutline = true"))
assertTrue(thumbnailView.contains("ViewOutlineProvider"))
assertTrue(thumbnailView.contains("outline.setRoundRect"))
assertTrue(thumbnailView.contains("R.dimen.radius_14"))
assertTrue(adapter.contains("private val latestAudioThumbnail: ImageView?"))
assertTrue(adapter.contains("private val latestAudioPointTag: ImageView?"))
assertTrue(adapter.contains("private val latestAudioTitle: TextView?"))
assertTrue(adapter.contains("private val latestAudioDuration: TextView?"))
assertTrue(adapter.contains("latestAudioTitle?.text = item.audioContent.title"))
assertTrue(adapter.contains("latestAudioThumbnail?.loadUrl"))
assertTrue(adapter.contains("latestAudioPointTag?.isVisible = item.audioContent.isPointAvailable"))
assertFalse(adapter.contains("private fun addAudioCard"))
assertFalse(adapter.contains("addAudioCard(item.audioContent)"))
}
@Test
fun `후원 섹션은 Figma 전용 layout과 row bind로 렌더링한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
val donationLayout = projectFile("app/src/main/res/layout/item_creator_channel_home_donation.xml").readText()
val donationRowLayout = projectFile("app/src/main/res/layout/item_creator_channel_home_donation_row.xml").readText()
assertTrue(donationLayout.contains("@layout/view_section_title"))
assertTrue(donationLayout.contains("@+id/hsv_donation_items"))
assertTrue(donationLayout.contains("@+id/ll_donation_items"))
assertTrue(donationLayout.contains("@+id/layout_donation_button"))
assertTrue(donationLayout.contains("@drawable/ic_new_donation"))
assertFalse(donationLayout.contains("@+id/tv_donation_button"))
assertFalse(donationLayout.contains("@+id/ll_section_items"))
val donationCardView = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelDonationCardView.kt"
).readText()
assertTrue(donationRowLayout.contains("CreatorChannelDonationCardView"))
assertTrue(donationRowLayout.contains("@+id/layout_donation_card"))
assertTrue(donationRowLayout.contains("@+id/layout_donation_header"))
assertTrue(donationRowLayout.contains("@+id/iv_donation_profile"))
assertTrue(donationRowLayout.contains("@+id/tv_donation_nickname"))
assertTrue(donationRowLayout.contains("@+id/tv_donation_created_at"))
assertTrue(donationRowLayout.contains("@+id/tv_donation_can"))
assertTrue(donationRowLayout.contains("@+id/tv_donation_message"))
assertTrue(donationRowLayout.contains("@drawable/ic_can"))
assertFalse(donationRowLayout.contains("android:layout_width=\"374dp\""))
assertTrue(adapter.contains("private val donationItems: LinearLayout?"))
assertTrue(adapter.contains("R.layout.item_creator_channel_home_donation_row"))
assertTrue(adapter.contains("donationItems?.addView(row)"))
assertTrue(adapter.contains("val visibleDonations = item.donations.take(MAX_DONATION_ITEM_COUNT)"))
assertTrue(adapter.contains("calculateCreatorChannelDonationCardWidthDp"))
assertTrue(adapter.contains("calculateCreatorChannelDonationHeaderColorRes(donation.can)"))
assertTrue(adapter.contains("R.string.creator_channel_donation_fallback_message"))
assertFalse(adapter.contains("private fun addDonationCard"))
assertFalse(adapter.contains("addDonationCard("))
}
@Test
fun `후원 can 수량은 요청된 배경색 resource 경계로 매핑한다`() {
assertEquals(R.color.gray_200, calculateCreatorChannelDonationHeaderColorRes(1))
assertEquals(R.color.gray_200, calculateCreatorChannelDonationHeaderColorRes(50))
assertEquals(R.color.green_400, calculateCreatorChannelDonationHeaderColorRes(51))
assertEquals(R.color.green_400, calculateCreatorChannelDonationHeaderColorRes(100))
assertEquals(R.color.creator_channel_donation_cyan, calculateCreatorChannelDonationHeaderColorRes(101))
assertEquals(R.color.creator_channel_donation_cyan, calculateCreatorChannelDonationHeaderColorRes(499))
assertEquals(R.color.red_400, calculateCreatorChannelDonationHeaderColorRes(500))
}
@Test
fun `후원 컴포넌트 width는 402dp 기준 최대 374dp이고 작은 화면에서는 비율 축소한다`() {
assertEquals(374, calculateCreatorChannelDonationCardWidthDp(402))
assertEquals(374, calculateCreatorChannelDonationCardWidthDp(430))
assertEquals(335, calculateCreatorChannelDonationCardWidthDp(360))
}
@Test
fun `후원 빈 메시지 fallback은 다국어 string resource로 제공한다`() {
val ko = projectFile("app/src/main/res/values/strings.xml").readText()
val en = projectFile("app/src/main/res/values-en/strings.xml").readText()
val ja = projectFile("app/src/main/res/values-ja/strings.xml").readText()
assertTrue(ko.contains("creator_channel_donation_fallback_message"))
assertTrue(ko.contains("%1\$d캔을 후원하였습니다."))
assertTrue(en.contains("creator_channel_donation_fallback_message"))
assertTrue(ja.contains("creator_channel_donation_fallback_message"))
}
@Test
fun `공지 섹션은 최대 3개 Figma feed card를 가로 row로 렌더링한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
val noticeLayout = projectFile("app/src/main/res/layout/item_creator_channel_home_notice.xml").readText()
val noticeRowLayout = projectFile("app/src/main/res/layout/item_creator_channel_home_notice_row.xml").readText()
val noticeCardView = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelNoticeCardView.kt"
).readText()
val noticeThumbnailView = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelNoticeThumbnailView.kt"
).readText()
assertTrue(noticeLayout.contains("@layout/view_section_title"))
assertTrue(noticeLayout.contains("@+id/hsv_notice_items"))
assertTrue(noticeLayout.contains("@+id/ll_notice_items"))
assertFalse(noticeLayout.contains("@+id/ll_section_items"))
assertTrue(noticeRowLayout.contains("CreatorChannelNoticeCardView"))
assertTrue(noticeRowLayout.contains("@+id/layout_notice_card"))
assertTrue(noticeRowLayout.contains("@drawable/ic_pin"))
assertTrue(noticeRowLayout.contains("@+id/tv_notice_label"))
assertTrue(noticeRowLayout.contains("@+id/iv_notice_profile"))
assertTrue(noticeRowLayout.contains("@+id/tv_notice_creator_name"))
assertTrue(noticeRowLayout.contains("@+id/tv_notice_created_at"))
assertTrue(noticeRowLayout.contains("@+id/tv_notice_content"))
assertTrue(noticeRowLayout.contains("CreatorChannelNoticeThumbnailView"))
assertTrue(noticeRowLayout.contains("@+id/iv_notice_thumbnail"))
assertFalse(noticeRowLayout.contains("android:clipToOutline"))
assertTrue(noticeCardView.contains("clipToOutline = true"))
assertTrue(noticeCardView.contains("ViewOutlineProvider"))
assertTrue(noticeCardView.contains("outline.setRoundRect"))
assertTrue(noticeCardView.contains("R.dimen.radius_14"))
assertTrue(noticeThumbnailView.contains("clipToOutline = true"))
assertTrue(noticeThumbnailView.contains("ViewOutlineProvider"))
assertTrue(noticeThumbnailView.contains("outline.setRoundRect"))
assertTrue(noticeThumbnailView.contains("R.dimen.radius_14"))
assertTrue(adapter.contains("private val noticeItems: LinearLayout?"))
assertTrue(adapter.contains("val visibleNotices = item.notices.take(MAX_NOTICE_ITEM_COUNT)"))
assertTrue(adapter.contains("R.layout.item_creator_channel_home_notice_row"))
assertTrue(adapter.contains("noticeItems?.addView(row)"))
assertTrue(adapter.contains("row.findViewById<ImageView>(R.id.iv_notice_profile).loadUrl"))
assertTrue(adapter.contains("row.findViewById<TextView>(R.id.tv_notice_creator_name).text = notice.creatorNickname"))
assertTrue(adapter.contains("row.findViewById<TextView>(R.id.tv_notice_created_at).text = notice.dateUtc"))
assertTrue(adapter.contains("row.findViewById<TextView>(R.id.tv_notice_content).text = notice.content"))
assertTrue(adapter.contains("noticeThumbnail.isVisible = !notice.imageUrl.isNullOrBlank()"))
assertTrue(adapter.contains("marginEnd = if (index == visibleNotices.lastIndex) 0 else 4.dp()"))
assertTrue(adapter.contains("private const val MAX_NOTICE_ITEM_COUNT = 3"))
val legacyNoticeBinding = """
private fun bindNotices(item: CreatorChannelHomeSection.Notices) {
item.notices.forEach { notice ->
addTextCard(
""".trimIndent()
assertFalse(adapter.contains(legacyNoticeBinding))
}
@Test
fun `공지 컴포넌트 width는 402dp 기준 최대 346dp이고 작은 화면에서는 비율 축소한다`() {
assertEquals(346, calculateCreatorChannelNoticeCardWidthDp(402))
assertEquals(346, calculateCreatorChannelNoticeCardWidthDp(430))
assertEquals(310, calculateCreatorChannelNoticeCardWidthDp(360))
}
@Test
fun `section adapter source는 가로 시리즈와 SNS 링크와 일정 타입 label을 보존한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
assertTrue(adapter.contains("HorizontalScrollView"))
assertTrue(adapter.contains("createHorizontalScrollRow"))
assertTrue(adapter.contains("createHorizontalScrollRow(row)"))
assertTrue(adapter.contains("iconResId = sns.iconResId"))
assertTrue(adapter.contains("url = sns.url"))
assertTrue(adapter.contains("schedule.type.labelResId"))
assertFalse(adapter.contains("schedule.type.code"))
}
@Test
fun `일정 섹션은 Figma 전용 layout과 row bind로 최대 3개를 렌더링한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
val scheduleLayout = projectFile("app/src/main/res/layout/item_creator_channel_home_schedule.xml").readText()
val scheduleRowLayout = projectFile("app/src/main/res/layout/item_creator_channel_home_schedule_row.xml").readText()
assertTrue(scheduleLayout.contains("@layout/view_section_title"))
assertTrue(scheduleLayout.contains("@+id/ll_schedule_timeline"))
assertTrue(scheduleLayout.contains("@+id/ll_schedule_items"))
assertFalse(scheduleLayout.contains("@+id/ll_section_items"))
assertTrue(scheduleRowLayout.contains("@+id/layout_schedule_row"))
assertTrue(scheduleRowLayout.contains("@+id/tv_schedule_date"))
assertTrue(scheduleRowLayout.contains("@+id/tv_schedule_day_of_week"))
assertTrue(scheduleRowLayout.contains("@+id/tv_schedule_title"))
assertTrue(scheduleRowLayout.contains("@+id/tv_schedule_type"))
assertTrue(scheduleRowLayout.contains("@+id/tv_schedule_time"))
assertTrue(adapter.contains("private val scheduleItems: LinearLayout?"))
assertTrue(adapter.contains("val visibleSchedules = item.schedules.take(MAX_SCHEDULE_ITEM_COUNT)"))
assertTrue(adapter.contains("R.layout.item_creator_channel_home_schedule_row"))
assertTrue(adapter.contains("scheduleItems?.addView(row)"))
assertTrue(adapter.contains("formatCreatorChannelScheduleDate(schedule.scheduledAtUtc)"))
assertTrue(adapter.contains("formatCreatorChannelScheduleDayOfWeek(schedule.scheduledAtUtc)"))
assertTrue(adapter.contains("formatCreatorChannelScheduleTime(schedule.scheduledAtUtc)"))
assertTrue(adapter.contains("row.setOnClickListener { onScheduleClick(schedule) }"))
assertTrue(adapter.contains("private const val MAX_SCHEDULE_ITEM_COUNT = 3"))
assertFalse(adapter.contains("private fun addScheduleRow"))
assertFalse(adapter.contains("addScheduleRow("))
}
@Test
fun `일정 timeline indicator는 표시 스케줄 개수에 맞춰 점과 line을 동적으로 만든다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
assertEquals(0, calculateCreatorChannelScheduleTimelineLineCount(0))
assertEquals(0, calculateCreatorChannelScheduleTimelineLineCount(1))
assertEquals(1, calculateCreatorChannelScheduleTimelineLineCount(2))
assertEquals(2, calculateCreatorChannelScheduleTimelineLineCount(3))
assertTrue(adapter.contains("private val scheduleTimeline: LinearLayout?"))
assertTrue(adapter.contains("scheduleTimeline?.removeAllViews()"))
assertTrue(adapter.contains("bindScheduleTimeline(visibleSchedules.size)"))
assertTrue(adapter.contains("repeat(count) { index ->"))
assertTrue(adapter.contains("R.drawable.bg_creator_channel_schedule_timeline_dot"))
assertTrue(adapter.contains("R.drawable.bg_creator_channel_schedule_timeline_line"))
assertTrue(adapter.contains("if (index < calculateCreatorChannelScheduleTimelineLineCount(count))"))
}
@Test
fun `일정 UTC 시간은 디바이스 timezone 기준 날짜 요일 시간으로 표시한다`() {
val timeZone = TimeZone.getTimeZone("Asia/Seoul")
val locale = Locale.KOREA
assertEquals("30", formatCreatorChannelScheduleDate("2026-06-29T15:00:00Z", timeZone, locale))
assertEquals("", formatCreatorChannelScheduleDayOfWeek("2026-06-29T15:00:00Z", timeZone, locale))
assertEquals("오전 12:00", formatCreatorChannelScheduleTime("2026-06-29T15:00:00Z", timeZone, locale))
}
@Test
fun `일정 클릭은 콘텐츠 상세와 라이브 상세 이동 계약을 연결한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt"
).readText()
assertTrue(source.contains("CreatorChannelHomeSectionAdapter(::onScheduleClicked)"))
assertTrue(source.contains("private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse)"))
assertTrue(source.contains("CreatorActivityType.Audio"))
assertTrue(source.contains("CreatorActivityType.LiveReplay"))
assertTrue(source.contains("AudioContentDetailActivity::class.java"))
assertTrue(source.contains("putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, schedule.targetId)"))
assertTrue(source.contains("CreatorActivityType.Live"))
assertTrue(source.contains("CreatorActivityType.Live -> showLiveRoomDetail(schedule.targetId)"))
assertTrue(source.contains("LiveRoomDetailFragment("))
assertTrue(source.contains("detailFragment.show(supportFragmentManager, detailFragment.tag)"))
}
@Test
fun `section adapter source는 오디오 콘텐츠를 단일 가로 스크롤 row로 표시한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
assertTrue(adapter.contains("private fun bindAudioContents"))
assertTrue(adapter.contains("item.audioContents.forEach { audioContent ->"))
assertTrue(adapter.contains("row.addView(createAudioTile(audioContent))"))
assertTrue(adapter.contains("sectionItems?.addView(createHorizontalScrollRow(row))"))
assertFalse(adapter.contains("item.audioContents.forEach { audioContent ->\n addAudioCard(audioContent)"))
}
@Test
fun `SNS source는 ic_sns 아이콘을 ImageView로 표시한다`() {
val uiModel = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelHomeUiModels.kt"
).readText()
val mapper = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelHomeMappers.kt"
).readText()
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
assertTrue(uiModel.contains("val iconResId: Int"))
assertTrue(mapper.contains("R.drawable.ic_sns_instagram"))
assertTrue(mapper.contains("R.drawable.ic_sns_youtube"))
assertTrue(mapper.contains("R.drawable.ic_sns_x"))
assertTrue(mapper.contains("R.drawable.ic_sns_kakao"))
assertTrue(mapper.contains("R.drawable.ic_sns_fancimm"))
assertTrue(adapter.contains("private fun createSnsButton(iconResId: Int, url: String, isLast: Boolean): ImageView"))
assertTrue(adapter.contains("setImageResource(iconResId)"))
assertFalse(adapter.contains("createText(label, R.style.Typography_Caption2, R.color.white"))
}
@Test
fun `SNS source는 유효 URL만 매핑하고 열 수 있는 intent만 실행한다`() {
val mapper = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/model/CreatorChannelHomeMappers.kt"
).readText()
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
assertTrue(mapper.contains("isValidCreatorChannelSnsUrl"))
assertTrue(adapter.contains("resolveActivity(itemView.context.packageManager)"))
assertTrue(adapter.contains("itemView.context.startActivity(intent)"))
assertFalse(adapter.contains("itemView.context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))"))
}
@Test
fun `SNS 버튼 크기는 402dp 기준 52dp를 최대값으로 하고 작은 화면에서는 비례 축소한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
assertEquals(52, calculateCreatorChannelSnsButtonSizeDp(402))
assertEquals(52, calculateCreatorChannelSnsButtonSizeDp(430))
assertEquals(47, calculateCreatorChannelSnsButtonSizeDp(360))
assertTrue(adapter.contains("calculateCreatorChannelSnsButtonSizeDp"))
assertFalse(adapter.contains("LinearLayout.LayoutParams(52.dp(), 52.dp())"))
}
@Test
fun `SNS 버튼 row는 작은 화면 비율 축소를 유지하고 마지막 아이콘 trailing margin은 제거한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/ui/CreatorChannelHomeSectionAdapter.kt"
).readText()
assertTrue(adapter.contains("item.items.forEachIndexed { index, sns ->"))
assertTrue(adapter.contains("isLast = index == item.items.lastIndex"))
assertTrue(adapter.contains("private fun createSnsButton(iconResId: Int, url: String, isLast: Boolean): ImageView"))
assertTrue(adapter.contains("marginEnd = if (isLast) 0 else 16.dp()"))
}
@Test
fun `section item layouts는 legacy generic card id를 제거하고 동적 컨테이너만 둔다`() {
val layoutNames = listOf(
"audio",
"series",
"community",
"fantalk",
"introduce",
"activity",
"sns"
)
layoutNames.forEach { name ->
val layout = projectFile("app/src/main/res/layout/item_creator_channel_home_$name.xml").readText()
assertTrue(layout.contains("@+id/ll_section_items"))
assertFalse(layout.contains("@+id/iv_thumbnail"))
assertFalse(layout.contains("@+id/tv_primary"))
assertFalse(layout.contains("@+id/tv_secondary"))
}
}
@Test
fun `Manifest source는 CreatorChannelHomeActivity를 등록한다`() {
val manifest = projectFile("app/src/main/AndroidManifest.xml").readText()
assertTrue(manifest.contains(".v2.creator.channel.CreatorChannelHomeActivity"))
}
@Test
fun `채팅과 DM Activity intent helper 계약을 참조한다`() {
val chatRoom = projectFile("app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt").readText()
val dmRoom = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt").readText()
assertTrue(chatRoom.contains("fun newIntent(context: Context, roomId: Long): Intent"))
assertTrue(chatRoom.contains("putExtra(EXTRA_ROOM_ID, roomId)"))
assertTrue(dmRoom.contains("fun newIntentByCreatorId(context: Context, creatorId: Long): Intent"))
assertTrue(dmRoom.contains("putExtra(EXTRA_CREATOR_ID, creatorId)"))
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
}