feat(creator): 후원 탭 화면을 구현한다

This commit is contained in:
2026-06-22 21:24:43 +09:00
parent 77d889d9ab
commit 5ca5da45ba
3 changed files with 422 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class CreatorChannelDonationActionTest {
@Test
fun `후원 fragment source는 Host와 탭 load pagination refresh viewport 계약을 제공한다`() {
val fragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragment.kt"
).readText()
assertTrue(fragment.contains("BaseFragment<FragmentCreatorChannelDonationBinding>"))
assertTrue(fragment.contains("private val viewModel: CreatorChannelDonationViewModel by viewModel()"))
assertTrue(fragment.contains("interface Host"))
assertTrue(fragment.contains("fun isCreatorChannelOwner(): Boolean"))
assertTrue(fragment.contains("fun onCreatorChannelDonationContentChanged()"))
assertTrue(
fragment.contains(
"fun onCreatorChannelDonationRequested(onSubmit: (can: Int, isSecret: Boolean, message: String) -> Unit)"
)
)
assertTrue(fragment.contains("fun onCreatorChannelDonationRankingAllClicked()"))
assertTrue(fragment.contains("fun onCreatorChannelDonationCompleted()"))
assertTrue(fragment.contains("fun newInstance(creatorId: Long): CreatorChannelDonationFragment"))
assertTrue(fragment.contains("viewModel.loadDonations(creatorId, isOwner = host.isCreatorChannelOwner())"))
assertTrue(fragment.contains("viewModel.loadMore()"))
assertTrue(fragment.contains("viewModel.refreshDonations()"))
assertTrue(fragment.contains("binding.root.minimumHeight = minHeight"))
}
@Test
fun `후원 fragment source는 owner일 때 floating button을 숨기고 후원 요청을 ViewModel에 전달한다`() {
val fragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragment.kt"
).readText()
assertTrue(fragment.contains("btnCreatorChannelDonationWrite.isVisible = !state.isOwner"))
assertTrue(fragment.contains("host.onCreatorChannelDonationRequested { can, isSecret, message ->"))
assertTrue(fragment.contains("viewModel.postChannelDonation(can, isSecret, message)"))
assertTrue(fragment.contains("viewModel.consumeDonationSuccessEvent()"))
assertTrue(fragment.contains("host.onCreatorChannelDonationCompleted()"))
}
@Test
fun `후원 fragment source는 성공 이벤트를 content 상태와 독립적으로 전달한다`() {
val fragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/CreatorChannelDonationFragment.kt"
).readText()
val observeViewModel = fragment.substringAfter("private fun observeViewModel()")
.substringBefore("private fun bindLoading()")
val bindContent = fragment.substringAfter("private fun bindContent")
.substringBefore("private fun handleDonationSuccessEvent")
assertTrue(observeViewModel.contains("handleDonationSuccessEvent()"))
assertFalse(bindContent.contains("consumeDonationSuccessEvent()"))
}
@Test
fun `후원 adapter source는 ranking 전체보기 callback과 donation binding을 제공한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/ui/CreatorChannelDonationAdapter.kt"
).readText()
val rankingAdapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/donation/ui/CreatorChannelDonationRankingAdapter.kt"
).readText()
assertTrue(adapter.contains("ItemCreatorChannelDonationBinding"))
assertTrue(adapter.contains("ItemCreatorChannelDonationRankingBinding"))
assertTrue(adapter.contains("submitItems"))
assertTrue(adapter.contains("onRankingAllClick"))
assertTrue(adapter.contains("btnCreatorChannelDonationRankingAll.setOnClickListener"))
assertTrue(adapter.contains("setBackgroundColor(root.context.getColor(item.headerColorResId))"))
assertTrue(adapter.contains("R.string.creator_channel_donation_can_format"))
assertTrue(adapter.contains("GridLayoutManager(itemView.context, 4)"))
assertTrue(rankingAdapter.contains("item.profileImageUrl"))
assertTrue(rankingAdapter.contains("item.rank.toString()"))
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
}

View File

@@ -0,0 +1,127 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation
import android.app.Application
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class CreatorChannelDonationFragmentLayoutTest {
@Test
fun `후원 fragment layout은 list empty error retry floating button을 제공한다`() {
val root = inflateView(R.layout.fragment_creator_channel_donation)
val layout = projectFile("app/src/main/res/layout/fragment_creator_channel_donation.xml").readText()
val countBar = requireNotNull(root.findViewById<View>(R.id.layout_creator_channel_donation_count_bar))
val donationList = requireNotNull(root.findViewById<RecyclerView>(R.id.rv_creator_channel_donation))
val emptyContainer = requireNotNull(root.findViewById<View>(R.id.layout_creator_channel_donation_empty))
val emptyMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_creator_channel_donation_empty_message))
val errorMessage = requireNotNull(root.findViewById<TextView>(R.id.tv_creator_channel_donation_error_message))
val retryButton = requireNotNull(root.findViewById<TextView>(R.id.btn_creator_channel_donation_retry))
val donationButton = requireNotNull(root.findViewById<ImageView>(R.id.btn_creator_channel_donation_write))
assertSame(root, countBar.parent)
assertSame(root, donationList.parent)
assertSame(root, emptyContainer.parent)
assertSame(emptyContainer, emptyMessage.parent)
assertSame(root, errorMessage.parent)
assertSame(root, retryButton.parent)
assertSame(root, donationButton.parent)
assertTrue(layout.contains("android:background=\"@color/black\""))
assertTrue(layout.contains("tools:listitem=\"@layout/item_creator_channel_donation\""))
assertTrue(!layout.contains("creator_channel_donation_sort"))
}
@Test
fun `후원 count bar는 전체 label과 count만 제공하고 sort UI를 만들지 않는다`() {
val root = inflateView(R.layout.fragment_creator_channel_donation)
val countBar = requireNotNull(root.findViewById<View>(R.id.layout_creator_channel_donation_count_bar))
val layout = projectFile("app/src/main/res/layout/fragment_creator_channel_donation.xml").readText()
assertNotNull(countBar.findViewById<TextView>(R.id.tv_creator_channel_donation_total_label))
assertNotNull(countBar.findViewById<TextView>(R.id.tv_creator_channel_donation_total_count))
assertTrue(layout.contains("android:layout_height=\"52dp\""))
assertTrue(!layout.contains("popup"))
assertTrue(!layout.contains("sort"))
}
@Test
fun `후원 ranking item layout은 타이틀 grid 전체보기 button을 제공한다`() {
val item = inflateView(R.layout.item_creator_channel_donation_ranking)
val layout = projectFile("app/src/main/res/layout/item_creator_channel_donation_ranking.xml").readText()
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_donation_ranking_title))
assertNotNull(item.findViewById<RecyclerView>(R.id.rv_creator_channel_donation_ranking_members))
assertNotNull(item.findViewById<TextView>(R.id.btn_creator_channel_donation_ranking_all))
assertTrue(layout.contains("android:text=\"@string/creator_channel_donation_ranking_title\""))
assertTrue(layout.contains("android:text=\"@string/creator_channel_donation_ranking_all\""))
assertTrue(layout.contains("android:layout_marginHorizontal=\"@dimen/spacing_14\""))
assertTrue(layout.contains("android:background=\"@drawable/bg_creator_channel_donation_card\""))
}
@Test
fun `후원 ranking member item layout은 프로필 닉네임 rank overlay를 제공한다`() {
val item = inflateView(R.layout.item_creator_channel_donation_ranking_member)
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_donation_ranking_profile))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_donation_ranking_rank))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_donation_ranking_nickname))
}
@Test
fun `후원 item layout은 profile nickname createdAt can badge message를 제공한다`() {
val item = inflateView(R.layout.item_creator_channel_donation)
val layout = projectFile("app/src/main/res/layout/item_creator_channel_donation.xml").readText()
assertNotNull(item.findViewById<View>(R.id.layout_creator_channel_donation_header))
assertNotNull(item.findViewById<ImageView>(R.id.iv_creator_channel_donation_profile))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_donation_nickname))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_donation_created_at))
assertNotNull(item.findViewById<View>(R.id.layout_creator_channel_donation_can_badge))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_donation_can))
assertNotNull(item.findViewById<TextView>(R.id.tv_creator_channel_donation_message))
assertTrue(layout.contains("android:src=\"@drawable/ic_bar_cash\""))
assertTrue(layout.contains("android:background=\"@drawable/bg_creator_channel_donation_card\""))
}
@Test
fun `후원 문자열은 한국어 영어 일본어에 추가된다`() {
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()
listOf(ko, en, ja).forEach { strings ->
assertTrue(strings.contains("name=\"creator_channel_donation_all_label\""))
assertTrue(strings.contains("name=\"creator_channel_donation_ranking_title\""))
assertTrue(strings.contains("name=\"creator_channel_donation_ranking_all\""))
assertTrue(strings.contains("name=\"creator_channel_donation_action\""))
assertTrue(strings.contains("name=\"creator_channel_donation_error_message\""))
assertTrue(strings.contains("name=\"creator_channel_donation_retry\""))
}
}
private fun inflateView(layoutResId: Int): View {
val context = ApplicationProvider.getApplicationContext<Application>()
return LayoutInflater.from(context).inflate(layoutResId, null, false)
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
}