feat(banner): 배너 어댑터와 뷰를 추가한다

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-28 00:08:41 +09:00
parent 2e5af796e4
commit fe509365e2
3 changed files with 388 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
package kr.co.vividnext.sodalive.v2.widget.banner
import android.app.Application
import android.content.Context
import android.graphics.Rect
import android.view.View
import android.view.View.MeasureSpec
import android.widget.FrameLayout
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.assertEquals
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
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class BannerViewTest {
@Test
fun `adapter는 빈 목록과 단일 목록에서 실제 아이템 개수를 사용한다`() {
val adapter = BannerAdapter()
adapter.submitItems(emptyList())
assertEquals(0, adapter.itemCount)
adapter.submitItems(listOf(sampleItem("1")))
assertEquals(1, adapter.itemCount)
}
@Test
fun `adapter는 가상 위치를 실제 아이템으로 변환하고 이미지 바인딩과 클릭 콜백을 전달한다`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val parent = FrameLayout(context)
val items = listOf(sampleItem("1"), sampleItem("2"))
var boundImageView: ImageView? = null
var boundItem: BannerItem? = null
var clickedItem: BannerItem? = null
val adapter = BannerAdapter(
onClickItem = { clickedItem = it },
onBindImage = { imageView, item ->
boundImageView = imageView
boundItem = item
}
)
adapter.submitItems(items)
val holder = adapter.onCreateViewHolder(parent, 0)
adapter.onBindViewHolder(holder, 3)
holder.itemView.performClick()
assertTrue(adapter.itemCount > items.size)
assertSame(holder.itemView.findViewById<ImageView>(R.id.iv_banner), boundImageView)
assertEquals(items[1], boundItem)
assertEquals(items[1], clickedItem)
}
@Test
fun `배너 view는 빈 목록이면 숨기고 단일 목록이면 counter를 숨긴다`() {
val view = inflateBannerView()
view.setItems(emptyList())
assertEquals(View.GONE, view.visibility)
view.setItems(listOf(sampleItem("1")))
assertEquals(View.VISIBLE, view.visibility)
assertEquals(View.GONE, view.findViewById<View>(R.id.layout_banner_counter).visibility)
}
@Test
fun `배너 view는 코드로 생성해도 내부 layout을 포함한다`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val view = BannerView(context)
view.setItems(listOf(sampleItem("1")))
assertNotNull(view.findViewById<RecyclerView>(R.id.rv_banner))
assertEquals(View.VISIBLE, view.visibility)
}
@Test
fun `배너 view는 carousel 목록이면 형식화된 counter를 표시한다`() {
val view = inflateBannerView()
view.setItems(listOf(sampleItem("1"), sampleItem("2")))
assertEquals(View.VISIBLE, view.visibility)
assertEquals(View.VISIBLE, view.findViewById<View>(R.id.layout_banner_counter).visibility)
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
assertEquals("/", view.findViewById<TextView>(R.id.tv_banner_counter_separator).text.toString())
assertEquals("02", view.findViewById<TextView>(R.id.tv_banner_total_count).text.toString())
}
@Test
fun `배너 view는 정사각형 item 크기와 좌우 padding 및 간격을 적용한다`() {
val view = inflateBannerView()
val recyclerView = view.findViewById<RecyclerView>(R.id.rv_banner)
view.setItems(listOf(sampleItem("1"), sampleItem("2")))
view.measure(exactly(402.dpToPx()), exactly(402.dpToPx()))
view.layout(0, 0, 402.dpToPx(), 402.dpToPx())
val holder = recyclerView.adapter!!.onCreateViewHolder(recyclerView, 0)
recyclerView.adapter!!.onBindViewHolder(holder, 0)
val itemOffset = Rect()
recyclerView.getItemDecorationAt(0).getItemOffsets(itemOffset, holder.itemView, recyclerView, RecyclerView.State())
assertEquals(20.dpToPx(), recyclerView.paddingLeft)
assertEquals(20.dpToPx(), recyclerView.paddingRight)
assertEquals(8.dpToPx(), itemOffset.right)
assertEquals(362.dpToPx(), holder.itemView.layoutParams.width)
assertEquals(362.dpToPx(), holder.itemView.layoutParams.height)
}
@Test
fun `배너 view는 단일 item이면 item 간격을 적용하지 않는다`() {
val view = inflateBannerView()
val recyclerView = view.findViewById<RecyclerView>(R.id.rv_banner)
view.setItems(listOf(sampleItem("1")))
view.measure(exactly(402.dpToPx()), exactly(402.dpToPx()))
view.layout(0, 0, 402.dpToPx(), 402.dpToPx())
val holder = recyclerView.adapter!!.onCreateViewHolder(recyclerView, 0)
recyclerView.adapter!!.onBindViewHolder(holder, 0)
val itemOffset = Rect()
recyclerView.getItemDecorationAt(0).getItemOffsets(itemOffset, holder.itemView, recyclerView, RecyclerView.State())
assertEquals(0, itemOffset.right)
}
@Test
fun `배너 item image는 radius clipping 대상으로 설정된다`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val adapter = BannerAdapter()
adapter.submitItems(listOf(sampleItem("1")))
val holder = adapter.onCreateViewHolder(FrameLayout(context), 0)
adapter.onBindViewHolder(holder, 0)
val imageView = holder.itemView.findViewById<ImageView>(R.id.iv_banner)
assertTrue(imageView.clipToOutline)
assertNotNull(imageView.outlineProvider)
}
private fun inflateBannerView(): BannerView {
val context = ApplicationProvider.getApplicationContext<Context>()
return BannerView(context)
}
private fun sampleItem(id: String) = BannerItem(
bannerId = id,
imageUrl = "https://example.com/banner-$id.png"
)
private fun exactly(size: Int): Int = MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY)
private fun Int.dpToPx(): Int {
val context = ApplicationProvider.getApplicationContext<Context>()
return (this * context.resources.displayMetrics.density).toInt()
}
}