test(creator): 채널 홈 후속 동작을 검증한다

This commit is contained in:
2026-06-16 17:27:52 +09:00
parent 1cd676bcb4
commit f6395b5a3e
4 changed files with 257 additions and 15 deletions

View File

@@ -46,26 +46,122 @@ class CreatorChannelActivitySourceTest {
}
@Test
fun `follow notify source는 미팔로우 직접 팔로우와 팔로우 중 알림 sheet를 연결한다`() {
fun `follow notify source는 Phase 11 직접 팔로우 알림 액션을 연결한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val layout = projectFile("app/src/main/res/layout/activity_creator_channel.xml").readText()
assertTrue(source.contains("CreatorFollowNotifyFragment"))
assertFalse(source.contains("CreatorFollowNotifyFragment"))
assertTrue(layout.contains("android:id=\"@+id/layout_follow_capsule\""))
assertTrue(layout.contains("android:visibility=\"gone\""))
assertTrue(source.contains("binding.layoutFollowCapsule.setOnClickListener"))
assertTrue(source.contains("binding.ivBell.setOnClickListener"))
assertTrue(source.contains("private fun onFollowActionClicked"))
assertTrue(source.contains("private fun onFollowCapsuleClicked"))
assertTrue(source.contains("private fun onBellClicked"))
assertTrue(source.contains("if (!header.isFollow)"))
assertTrue(source.contains("homeActionDelegate?.follow(follow = true, notify = true)"))
assertTrue(source.contains("showFollowNotifyFragment"))
assertTrue(source.contains("onClickNotifyAll = { homeActionDelegate?.follow(follow = true, notify = true) }"))
assertTrue(source.contains("onClickNotifyNone = { homeActionDelegate?.follow(follow = true, notify = false) }"))
assertTrue(source.contains("onClickUnFollow = { homeActionDelegate?.follow(follow = false, notify = false) }"))
assertTrue(source.contains("homeActionDelegate?.follow(follow = true, notify = !header.isNotify)"))
assertTrue(source.contains("homeActionDelegate?.follow(follow = false, notify = false)"))
assertFalse(source.contains("showFollowNotifyFragment"))
assertTrue(source.contains("onCreatorChannelFollowProgressChanged"))
assertTrue(source.contains("binding.layoutFollowCapsule.isEnabled = titleBarState.isActionEnabled"))
assertTrue(source.contains("binding.ivBell.isEnabled = titleBarState.isActionEnabled"))
}
@Test
fun `Phase 11 more source는 기존 프로필 메뉴 의미를 BottomSheet로 연결한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val sheet = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelMoreBottomSheet.kt"
).readText()
val layout = projectFile("app/src/main/res/layout/dialog_creator_channel_more.xml").readText()
assertTrue(source.contains("CreatorChannelMoreBottomSheet.newInstance"))
assertTrue(source.contains("showUserReportDialog"))
assertTrue(source.contains("showProfileReportDialog"))
assertTrue(source.contains("homeActionDelegate?.blockUser"))
assertFalse(source.contains("creator_channel_more_ready"))
assertTrue(sheet.contains("BottomSheetDialogFragment"))
assertTrue(sheet.contains("onClickBlock"))
assertFalse(sheet.contains("onClickUnblock"))
assertFalse(sheet.contains("isBlocked"))
assertTrue(sheet.contains("onClickUserReport"))
assertTrue(sheet.contains("onClickProfileReport"))
assertTrue(layout.contains("@+id/tv_user_block"))
assertTrue(layout.contains("@string/menu_user_block"))
assertTrue(layout.contains("@+id/tv_user_report"))
assertTrue(layout.contains("@string/menu_user_report"))
assertTrue(layout.contains("@+id/tv_profile_report"))
assertTrue(layout.contains("@string/menu_profile_report"))
}
@Test
fun `Phase 11 owner source는 본인 페이지 상단 타인 액션을 숨긴다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val dto = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/data/CreatorChannelHomeModels.kt"
).readText()
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()
assertFalse(dto.contains("@SerializedName(\"isOwner\")"))
assertFalse(dto.contains("@SerializedName(\"isBlock\")"))
assertTrue(uiModel.contains("val isOwner: Boolean"))
assertFalse(uiModel.contains("val isBlock: Boolean"))
assertTrue(mapper.contains("fun CreatorChannelHomeResponse.toUiContent(currentMemberId: Long)"))
assertTrue(mapper.contains("isOwner = creator.creatorId == currentMemberId"))
assertTrue(source.contains("if (header.isOwner)"))
assertTrue(source.contains("binding.layoutFollowCapsule.visibility = View.GONE"))
assertTrue(source.contains("binding.ivBell.visibility = View.GONE"))
assertTrue(source.contains("binding.ivMore.visibility = View.GONE"))
assertTrue(source.contains("isChatVisible = !header.isOwner"))
assertTrue(source.contains("CreatorChannelMoreBottomSheet.newInstance()"))
assertFalse(source.contains("onClickUnblock"))
}
@Test
fun `Phase 11 owner DM source는 MainV2 채팅 DM 필터로 진입한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val layout = projectFile("app/src/main/res/layout/activity_creator_channel.xml").readText()
val main = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText()
val chat = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt").readText()
val filter = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomFilter.kt").readText()
val strings = projectFile("app/src/main/res/values/strings.xml").readText()
assertTrue(strings.contains("name=\"creator_channel_dm_check_button\">DM 확인하기"))
assertTrue(layout.contains("android:id=\"@+id/tv_chat_button\""))
assertTrue(layout.contains("android:id=\"@+id/tv_dm_button\""))
assertTrue(layout.contains("tools:visibility=\"visible\""))
assertTrue(source.contains("MainV2Activity.newChatDmIntent(this)"))
assertTrue(
source.contains(
"if (header.isOwner) R.string.creator_channel_dm_check_button else R.string.creator_channel_dm_button"
)
)
assertTrue(source.contains("isDmVisible = header.isOwner || header.isDmAvailable"))
assertTrue(main.contains("fun newChatDmIntent(context: Context): Intent"))
assertTrue(main.contains("Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP"))
assertTrue(main.contains("putExtra(EXTRA_CHAT_FILTER, ChatRoomFilter.DM.name)"))
assertTrue(main.contains("openChatWithInitialFilter"))
assertTrue(main.contains("ChatMainFragment.newInstance(initialChatFilter)"))
assertTrue(chat.contains("fun newInstance(initialFilter: ChatRoomFilter? = null): ChatMainFragment"))
assertTrue(chat.contains("selectedIndex = initialFilter.tabIndex"))
assertTrue(chat.contains("fun selectFilter(filter: ChatRoomFilter)"))
assertTrue(filter.contains("DM(\"DM\", 2)"))
assertTrue(source.contains("DmChatRoomActivity.newIntentByCreatorId(this, creatorId)"))
}
@Test
fun `Phase 10 컨테이너 layout은 TabLayout과 ViewPager2를 가진다`() {
val layout = projectFile("app/src/main/res/layout/activity_creator_channel.xml").readText()
@@ -190,6 +286,37 @@ class CreatorChannelActivitySourceTest {
assertTrue(source.contains("Color.TRANSPARENT"))
}
@Test
fun `Phase 11 title bar는 sticky black 상태에서 닉네임을 표시한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
val layout = projectFile("app/src/main/res/layout/activity_creator_channel.xml").readText()
assertTrue(layout.contains("@+id/tv_title_nickname"))
assertTrue(layout.contains("style=\"@style/Typography.Heading2\""))
assertTrue(layout.contains("android:ellipsize=\"end\""))
assertTrue(layout.contains("android:maxLines=\"1\""))
assertTrue(layout.contains("app:layout_constraintStart_toEndOf=\"@id/iv_back\""))
assertTrue(layout.contains("app:layout_constraintEnd_toStartOf=\"@id/title_action_container\""))
assertTrue(source.contains("binding.tvTitleNickname.text = header.nickname"))
assertTrue(source.contains("binding.tvTitleNickname.isVisible = shouldUseBlackTitleBar"))
}
@Test
fun `Phase 11 tab bar typography는 선택 비선택 모두 16sp medium을 사용한다`() {
val layout = projectFile("app/src/main/res/layout/activity_creator_channel.xml").readText()
val typography = projectFile("app/src/main/res/values/typography.xml").readText()
assertTrue(layout.contains("app:tabTextAppearance=\"@style/CreatorChannelTabText\""))
assertTrue(typography.contains("<style name=\"CreatorChannelTabText\" parent=\"Typography.Body2\">"))
assertTrue(typography.contains("<item name=\"android:fontFamily\">@font/medium</item>"))
assertTrue(typography.contains("<item name=\"android:textSize\">16sp</item>"))
assertTrue(typography.contains("<item name=\"android:letterSpacing\">0</item>"))
assertTrue(typography.contains("<item name=\"lineHeight\">23.2sp</item>"))
assertFalse(layout.contains("app:tabSelectedTextAppearance"))
}
@Test
fun `creator channel home은 어두운 header 위 status bar icon을 밝게 표시한다`() {
val source = projectFile(
@@ -375,6 +502,22 @@ class CreatorChannelActivitySourceTest {
assertFalse(adapter.contains("addActivityRow("))
}
@Test
fun `활동 summary string resource는 label value row 구조에서 사용하지 않는다`() {
val stringFiles = listOf(
"app/src/main/res/values/strings.xml",
"app/src/main/res/values-en/strings.xml",
"app/src/main/res/values-ja/strings.xml"
)
stringFiles.forEach { path ->
assertFalse(
"$path contains unused creator_channel_activity_summary",
projectFile(path).readText().contains("creator_channel_activity_summary")
)
}
}
@Test
fun `SNS 섹션은 전용 layout 아이콘 row와 안전한 링크 실행을 유지한다`() {
val adapter = projectFile(

View File

@@ -26,7 +26,7 @@ class CreatorChannelHomeMapperTest {
@Test
fun `크리에이터 정보와 탭 순서를 UI content에 그대로 매핑한다`() {
val content = response().toUiContent()
val content = response().toUiContent(currentMemberId = 100L)
assertEquals(100L, content.header.creatorId)
assertEquals(200L, content.header.characterId)
@@ -37,6 +37,7 @@ class CreatorChannelHomeMapperTest {
assertFalse(content.header.isNotify)
assertTrue(content.header.isAiChatAvailable)
assertTrue(content.header.isDmAvailable)
assertTrue(content.header.isOwner)
assertEquals(
listOf(
R.string.creator_channel_tab_home,
@@ -71,7 +72,7 @@ class CreatorChannelHomeMapperTest {
youtubeUrl = "",
kakaoOpenChatUrl = ""
)
).toUiContent()
).toUiContent(currentMemberId = 1L)
assertFalse(content.sections.any { it is CreatorChannelHomeSection.CurrentLive })
assertFalse(content.sections.any { it is CreatorChannelHomeSection.LatestAudioContent })
@@ -88,7 +89,7 @@ class CreatorChannelHomeMapperTest {
@Test
fun `값이 있는 홈 필드는 section으로 생성하고 blank SNS URL만 제외한다`() {
val content = response().toUiContent()
val content = response().toUiContent(currentMemberId = 1L)
assertTrue(content.sections.any { it is CreatorChannelHomeSection.CurrentLive })
assertTrue(content.sections.any { it is CreatorChannelHomeSection.LatestAudioContent })
@@ -116,7 +117,7 @@ class CreatorChannelHomeMapperTest {
youtubeUrl = "https://youtube.example",
kakaoOpenChatUrl = "kakao-open-chat"
)
).toUiContent()
).toUiContent(currentMemberId = 1L)
val sns = content.sections.filterIsInstance<CreatorChannelHomeSection.Sns>().single()
@@ -140,7 +141,7 @@ class CreatorChannelHomeMapperTest {
schedule(id = 1L, scheduledAtUtc = "2026-06-12T10:00:00Z"),
schedule(id = 3L, scheduledAtUtc = "2026-06-12T12:00:00Z")
)
).toUiContent()
).toUiContent(currentMemberId = 1L)
val schedules = content.sections.filterIsInstance<CreatorChannelHomeSection.Schedules>().single()

View File

@@ -155,6 +155,58 @@ class CreatorChannelHomeViewModelTest {
assertEquals(false, viewModel.isFollowInProgressLiveData.requireValue())
}
@Test
fun `팔로잉 상태 알림 아이콘 액션은 알림 끄기 조합을 호출한다`() {
whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, response(), null)))
whenever(repository.followCreator(100L, true, false, "Bearer test-token")).thenReturn(
Single.just(ApiResponse(true, Any(), null))
)
viewModel.loadHome(100L)
viewModel.follow(follow = true, notify = false)
verify(repository).followCreator(100L, true, false, "Bearer test-token")
val state = viewModel.homeStateLiveData.requireValue() as CreatorChannelHomeUiState.Content
assertTrue(state.header.isFollow)
assertEquals(false, state.header.isNotify)
}
@Test
fun `팔로잉 알림 꺼짐 상태 알림 아이콘 액션은 알림 켜기 조합을 호출한다`() {
whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(
Single.just(ApiResponse(true, response(isNotify = false), null))
)
whenever(repository.followCreator(100L, true, true, "Bearer test-token")).thenReturn(
Single.just(ApiResponse(true, Any(), null))
)
viewModel.loadHome(100L)
viewModel.follow(follow = true, notify = true)
verify(repository).followCreator(100L, true, true, "Bearer test-token")
val state = viewModel.homeStateLiveData.requireValue() as CreatorChannelHomeUiState.Content
assertTrue(state.header.isFollow)
assertTrue(state.header.isNotify)
}
@Test
fun `팔로잉 상태 capsule 액션은 팔로우 취소 조합을 호출한다`() {
whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, response(), null)))
whenever(repository.followCreator(100L, false, false, "Bearer test-token")).thenReturn(
Single.just(ApiResponse(true, Any(), null))
)
viewModel.loadHome(100L)
viewModel.follow(follow = false, notify = false)
verify(repository).followCreator(100L, false, false, "Bearer test-token")
val state = viewModel.homeStateLiveData.requireValue() as CreatorChannelHomeUiState.Content
assertEquals(false, state.header.isFollow)
assertEquals(false, state.header.isNotify)
val toastEvent = viewModel.toastLiveData.requireValue()
assertEquals(R.string.creator_channel_unfollow_success, toastEvent?.consume()?.resId)
}
@Test
fun `팔로우 실패는 현재 content를 유지하고 unknown toast만 emit한다`() {
whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, response(), null)))
@@ -217,6 +269,49 @@ class CreatorChannelHomeViewModelTest {
assertEquals(null, toastEvent?.consume())
}
@Test
fun `차단 성공은 block API를 호출하고 차단 완료 토스트를 emit한다`() {
whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, response(), null)))
whenever(repository.blockUser(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, Any(), null)))
viewModel.loadHome(100L)
viewModel.blockUser()
verify(repository).blockUser(100L, "Bearer test-token")
val toastEvent = viewModel.toastLiveData.requireValue()
assertEquals(R.string.creator_channel_block_success, toastEvent?.consume()?.resId)
}
@Test
fun `사용자 신고 실패는 접수 완료 대신 unknown toast를 emit한다`() {
whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, response(), null)))
whenever(repository.reportUser(100L, "reason", "Bearer test-token")).thenReturn(
Single.just(ApiResponse(false, null, "failed"))
)
viewModel.loadHome(100L)
viewModel.reportUser("reason")
verify(repository).reportUser(100L, "reason", "Bearer test-token")
val toastEvent = viewModel.toastLiveData.requireValue()
assertEquals(R.string.common_error_unknown, toastEvent?.consume()?.resId)
}
@Test
fun `사용자 신고 throwable은 접수 완료 대신 unknown toast를 emit한다`() {
whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, response(), null)))
whenever(repository.reportUser(100L, "reason", "Bearer test-token")).thenReturn(
Single.error(IllegalStateException("network"))
)
viewModel.loadHome(100L)
viewModel.reportUser("reason")
verify(repository).reportUser(100L, "reason", "Bearer test-token")
val toastEvent = viewModel.toastLiveData.requireValue()
assertEquals(R.string.common_error_unknown, toastEvent?.consume()?.resId)
}
private fun setImmediateRxSchedulers() {
val trampoline = { _: Scheduler -> Schedulers.trampoline() }
RxJavaPlugins.setIoSchedulerHandler(trampoline)
@@ -224,7 +319,10 @@ class CreatorChannelHomeViewModelTest {
RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
}
private fun response(isFollow: Boolean = true) = CreatorChannelHomeResponse(
private fun response(
isFollow: Boolean = true,
isNotify: Boolean = false
) = CreatorChannelHomeResponse(
creator = CreatorChannelCreatorResponse(
creatorId = 100L,
characterId = 200L,
@@ -234,7 +332,7 @@ class CreatorChannelHomeViewModelTest {
isAiChatAvailable = true,
isDmAvailable = true,
isFollow = isFollow,
isNotify = false
isNotify = isNotify
),
currentLive = CreatorChannelLiveResponse(1L, "라이브", null, "2026-06-11T12:00:00Z", 0, false),
latestAudioContent = CreatorChannelAudioContentResponse(1L, "오디오", null, null, 0, true, false, null, null),

View File

@@ -101,7 +101,7 @@ class ChatMainFragmentLayoutTest {
assertTrue(source.contains("R.string.screen_chat_filter_all"))
assertTrue(source.contains("R.string.screen_chat_filter_ai"))
assertTrue(source.contains("R.string.screen_chat_filter_dm"))
assertTrue(source.contains("viewModel.loadFirstPage()"))
assertTrue(source.contains("viewModel.loadFirstPage(initialFilter ?: ChatRoomFilter.ALL)"))
}
@Test