test(chat-room): 타이핑 인디케이터 표시/중복/숨김 테스트 추가
- showTypingIndicator 중복 호출 시 중복 삽입 방지 검증 - hideTypingIndicator 안전성 검증(표시되지 않은 경우도 안전) - NPE 회귀 방지 fix(adapter): RecyclerView 미부착 상태에서 notify 호출로 NPE 발생 방지
This commit is contained in:
		@@ -35,6 +35,9 @@ sealed class ChatListItem {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
					class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 테스트/비연결 환경에서 notify* 호출로 인한 NPE 방지용 플래그
 | 
				
			||||||
 | 
					    private var isRecyclerViewAttached: Boolean = false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    interface Callback {
 | 
					    interface Callback {
 | 
				
			||||||
        fun onRetrySend(localId: String)
 | 
					        fun onRetrySend(localId: String)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -122,8 +125,10 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
        // 목록의 마지막에 추가
 | 
					        // 목록의 마지막에 추가
 | 
				
			||||||
        items.add(ChatListItem.TypingIndicator)
 | 
					        items.add(ChatListItem.TypingIndicator)
 | 
				
			||||||
        isTypingVisible = true
 | 
					        isTypingVisible = true
 | 
				
			||||||
 | 
					        if (isRecyclerViewAttached) {
 | 
				
			||||||
            notifyItemInserted(items.lastIndex)
 | 
					            notifyItemInserted(items.lastIndex)
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * 타이핑 인디케이터를 숨긴다(제거).
 | 
					     * 타이핑 인디케이터를 숨긴다(제거).
 | 
				
			||||||
@@ -133,9 +138,11 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
        if (index >= 0) {
 | 
					        if (index >= 0) {
 | 
				
			||||||
            items.removeAt(index)
 | 
					            items.removeAt(index)
 | 
				
			||||||
            isTypingVisible = false
 | 
					            isTypingVisible = false
 | 
				
			||||||
 | 
					            if (isRecyclerViewAttached) {
 | 
				
			||||||
                notifyItemRemoved(index)
 | 
					                notifyItemRemoved(index)
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @SuppressLint("NotifyDataSetChanged")
 | 
					    @SuppressLint("NotifyDataSetChanged")
 | 
				
			||||||
    fun setItems(newItems: List<ChatListItem>) {
 | 
					    fun setItems(newItems: List<ChatListItem>) {
 | 
				
			||||||
@@ -480,6 +487,7 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
        super.onAttachedToRecyclerView(recyclerView)
 | 
					        super.onAttachedToRecyclerView(recyclerView)
 | 
				
			||||||
        // RecyclerView와 연결된 시점에 안정 ID 활성화 (JVM 테스트에서 NPE 회피)
 | 
					        // RecyclerView와 연결된 시점에 안정 ID 활성화 (JVM 테스트에서 NPE 회피)
 | 
				
			||||||
        setHasStableIds(true)
 | 
					        setHasStableIds(true)
 | 
				
			||||||
 | 
					        isRecyclerViewAttached = true
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
 | 
					    override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
 | 
				
			||||||
@@ -496,6 +504,11 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
        super.onViewDetachedFromWindow(holder)
 | 
					        super.onViewDetachedFromWindow(holder)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
 | 
				
			||||||
 | 
					        super.onDetachedFromRecyclerView(recyclerView)
 | 
				
			||||||
 | 
					        isRecyclerViewAttached = false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
 | 
					    override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
 | 
				
			||||||
        when (holder) {
 | 
					        when (holder) {
 | 
				
			||||||
            is TypingIndicatorViewHolder -> holder.stopTypingAnimation()
 | 
					            is TypingIndicatorViewHolder -> holder.stopTypingAnimation()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,6 +5,34 @@ import org.junit.Test
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class ChatMessageAdapterTest {
 | 
					class ChatMessageAdapterTest {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Test
 | 
				
			||||||
 | 
					    fun `typing indicator shows only once and hides correctly`() {
 | 
				
			||||||
 | 
					        val adapter = ChatMessageAdapter()
 | 
				
			||||||
 | 
					        // 초기 아이템 1개 추가
 | 
				
			||||||
 | 
					        val base = listOf(
 | 
				
			||||||
 | 
					            ChatListItem.AiMessage(ChatMessage(1, "hi", "", mine = false, createdAt = 1L))
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        adapter.setItemsForTest(base)
 | 
				
			||||||
 | 
					        val initialCount = adapter.itemCount
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // 1) 표시: 하나 추가되고 마지막 아이템이 TypingIndicator 여야 함
 | 
				
			||||||
 | 
					        adapter.showTypingIndicator()
 | 
				
			||||||
 | 
					        assertEquals(initialCount + 1, adapter.itemCount)
 | 
				
			||||||
 | 
					        assertEquals(ChatMessageAdapter.VIEW_TYPE_TYPING_INDICATOR, adapter.getItemViewType(adapter.itemCount - 1))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // 2) 중복 표시 시 개수 변화 없음
 | 
				
			||||||
 | 
					        adapter.showTypingIndicator()
 | 
				
			||||||
 | 
					        assertEquals(initialCount + 1, adapter.itemCount)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // 3) 숨김: 다시 원래 개수로 돌아감
 | 
				
			||||||
 | 
					        adapter.hideTypingIndicator()
 | 
				
			||||||
 | 
					        assertEquals(initialCount, adapter.itemCount)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // 4) 숨길 것이 없을 때 호출해도 안전 (변화 없음)
 | 
				
			||||||
 | 
					        adapter.hideTypingIndicator()
 | 
				
			||||||
 | 
					        assertEquals(initialCount, adapter.itemCount)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @Test
 | 
					    @Test
 | 
				
			||||||
    fun `getItemViewType returns correct types`() {
 | 
					    fun `getItemViewType returns correct types`() {
 | 
				
			||||||
        val adapter = ChatMessageAdapter()
 | 
					        val adapter = ChatMessageAdapter()
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user