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 10:50:23 +09:00
parent 31b4e93bed
commit bc15a0997e
3 changed files with 228 additions and 5 deletions

View File

@@ -18,7 +18,9 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import java.time.Duration
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
@@ -148,11 +150,152 @@ class BannerViewTest {
assertNotNull(imageView.outlineProvider)
}
@Test
fun `배너 view는 attach 후 5초가 지나면 다음 배너로 자동 이동한다`() {
val view = inflateBannerView()
view.setItems(listOf(sampleItem("1"), sampleItem("2"), sampleItem("3")))
view.measure(exactly(402.dpToPx()), exactly(402.dpToPx()))
view.layout(0, 0, 402.dpToPx(), 402.dpToPx())
view.dispatchAttachedForTest()
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000))
assertEquals("02", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
}
@Test
fun `배너 view는 사용자 drag 중에는 자동 이동 timer를 다시 시작하지 않는다`() {
val view = inflateBannerView()
view.setItems(listOf(sampleItem("1"), sampleItem("2"), sampleItem("3")))
view.measure(exactly(402.dpToPx()), exactly(402.dpToPx()))
view.layout(0, 0, 402.dpToPx(), 402.dpToPx())
view.dispatchAttachedForTest()
view.dispatchScrollStateForTest(RecyclerView.SCROLL_STATE_DRAGGING)
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000))
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
view.dispatchScrollStateForTest(RecyclerView.SCROLL_STATE_IDLE)
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(4_999))
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(1))
assertEquals("02", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
}
@Test
fun `배너 view는 detach 되면 자동 이동 callback을 제거한다`() {
val view = inflateBannerView()
view.setItems(listOf(sampleItem("1"), sampleItem("2")))
view.measure(exactly(402.dpToPx()), exactly(402.dpToPx()))
view.layout(0, 0, 402.dpToPx(), 402.dpToPx())
view.dispatchAttachedForTest()
view.dispatchDetachedForTest()
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000))
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
}
@Test
fun `배너 view는 단일 item이면 attach 후 5초가 지나도 자동 이동하지 않는다`() {
val view = inflateBannerView()
view.setItems(listOf(sampleItem("1")))
view.measure(exactly(402.dpToPx()), exactly(402.dpToPx()))
view.layout(0, 0, 402.dpToPx(), 402.dpToPx())
view.dispatchAttachedForTest()
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000))
assertEquals(View.GONE, view.findViewById<View>(R.id.layout_banner_counter).visibility)
}
@Test
fun `배너 view는 마지막 배너 다음에 첫 번째 counter로 순환한다`() {
val view = inflateBannerView()
view.setItems(listOf(sampleItem("1"), sampleItem("2")))
view.measure(exactly(402.dpToPx()), exactly(402.dpToPx()))
view.layout(0, 0, 402.dpToPx(), 402.dpToPx())
view.dispatchAttachedForTest()
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000))
assertEquals("02", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000))
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
}
@Test
fun `배너 view는 자동 전환 animation duration을 350ms로 사용한다`() {
assertEquals(350, BannerView.scrollAnimationDurationMsForTest())
}
@Test
fun `배너 view는 현재 virtual position 기준으로 다음 배너로 이동한다`() {
val view = inflateBannerView()
val recyclerView = view.findViewById<RecyclerView>(R.id.rv_banner)
view.setItems(listOf(sampleItem("1"), sampleItem("2"), sampleItem("3")))
val adapter = requireNotNull(recyclerView.adapter as? BannerAdapter)
val previousVirtualPosition = adapter.startPosition(0) - 1
view.measure(exactly(402.dpToPx()), exactly(402.dpToPx()))
view.layout(0, 0, 402.dpToPx(), 402.dpToPx())
view.setCurrentPositionForTest(previousVirtualPosition)
view.dispatchAttachedForTest()
shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(5_000))
assertEquals(previousVirtualPosition + 1, view.currentAdapterPositionForTest())
assertEquals("01", view.findViewById<TextView>(R.id.tv_banner_current_index).text.toString())
}
private fun inflateBannerView(): BannerView {
val context = ApplicationProvider.getApplicationContext<Context>()
return BannerView(context)
}
private fun BannerView.dispatchAttachedForTest() {
BannerView::class.java.getDeclaredMethod("onAttachedToWindow").apply {
isAccessible = true
invoke(this@dispatchAttachedForTest)
}
}
private fun BannerView.dispatchDetachedForTest() {
BannerView::class.java.getDeclaredMethod("onDetachedFromWindow").apply {
isAccessible = true
invoke(this@dispatchDetachedForTest)
}
}
private fun BannerView.dispatchScrollStateForTest(state: Int) {
val listenerField = BannerView::class.java.getDeclaredField("bannerScrollListener").apply { isAccessible = true }
val listener = listenerField.get(this) as RecyclerView.OnScrollListener
listener.onScrollStateChanged(findViewById(R.id.rv_banner), state)
}
private fun BannerView.setCurrentPositionForTest(position: Int) {
BannerView::class.java.getDeclaredField("currentAdapterPosition").apply {
isAccessible = true
setInt(this@setCurrentPositionForTest, position)
}
BannerView::class.java.getDeclaredField("currentIndex").apply {
isAccessible = true
setInt(this@setCurrentPositionForTest, requireNotNull(findViewById<RecyclerView>(R.id.rv_banner).adapter as? BannerAdapter).toRealIndex(position))
}
}
private fun BannerView.currentAdapterPositionForTest(): Int {
return BannerView::class.java.getDeclaredField("currentAdapterPosition").run {
isAccessible = true
getInt(this@currentAdapterPositionForTest)
}
}
private fun sampleItem(id: String) = BannerItem(
bannerId = id,
imageUrl = "https://example.com/banner-$id.png"