feat(chat-ui): 메시지 그룹화, 시간 포맷팅, Repository 테스트 추가
This commit is contained in:
		@@ -209,4 +209,11 @@ dependencies {
 | 
				
			|||||||
    implementation "com.kakao.sdk:v2-user:2.21.0"
 | 
					    implementation "com.kakao.sdk:v2-user:2.21.0"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    implementation 'io.github.glailton.expandabletextview:expandabletextview:1.0.4'
 | 
					    implementation 'io.github.glailton.expandabletextview:expandabletextview:1.0.4'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // ----- Test dependencies -----
 | 
				
			||||||
 | 
					    testImplementation 'junit:junit:4.13.2'
 | 
				
			||||||
 | 
					    testImplementation 'org.mockito:mockito-core:5.12.0'
 | 
				
			||||||
 | 
					    testImplementation 'org.mockito:mockito-inline:5.2.0'
 | 
				
			||||||
 | 
					    testImplementation 'org.mockito.kotlin:mockito-kotlin:5.3.1'
 | 
				
			||||||
 | 
					    testImplementation 'io.mockk:mockk:1.13.10'
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,6 +14,7 @@ import android.view.animation.Animation
 | 
				
			|||||||
import android.view.animation.AnimationUtils
 | 
					import android.view.animation.AnimationUtils
 | 
				
			||||||
import android.widget.TextView
 | 
					import android.widget.TextView
 | 
				
			||||||
import androidx.annotation.LayoutRes
 | 
					import androidx.annotation.LayoutRes
 | 
				
			||||||
 | 
					import androidx.annotation.VisibleForTesting
 | 
				
			||||||
import androidx.core.view.isVisible
 | 
					import androidx.core.view.isVisible
 | 
				
			||||||
import androidx.recyclerview.widget.RecyclerView
 | 
					import androidx.recyclerview.widget.RecyclerView
 | 
				
			||||||
import kr.co.vividnext.sodalive.R
 | 
					import kr.co.vividnext.sodalive.R
 | 
				
			||||||
@@ -48,6 +49,34 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
        const val VIEW_TYPE_AI_MESSAGE = 2
 | 
					        const val VIEW_TYPE_AI_MESSAGE = 2
 | 
				
			||||||
        const val VIEW_TYPE_NOTICE = 3
 | 
					        const val VIEW_TYPE_NOTICE = 3
 | 
				
			||||||
        const val VIEW_TYPE_TYPING_INDICATOR = 4
 | 
					        const val VIEW_TYPE_TYPING_INDICATOR = 4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * [list]와 [position]을 기준으로 그룹화 여부와 해당 아이템이 그룹의 마지막인지 계산한다.
 | 
				
			||||||
 | 
					         * 테스트를 위해 노출된 유틸 함수이며, onBindViewHolder의 로직과 동일한 판정을 수행한다.
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        @VisibleForTesting
 | 
				
			||||||
 | 
					        internal fun computeGroupingFlags(list: List<ChatListItem>, position: Int): Pair<Boolean, Boolean> {
 | 
				
			||||||
 | 
					            fun isSameSender(prev: ChatListItem?, curr: ChatListItem?): Boolean {
 | 
				
			||||||
 | 
					                if (prev == null || curr == null) return false
 | 
				
			||||||
 | 
					                val p = when (prev) {
 | 
				
			||||||
 | 
					                    is ChatListItem.UserMessage -> prev.data.mine
 | 
				
			||||||
 | 
					                    is ChatListItem.AiMessage -> prev.data.mine
 | 
				
			||||||
 | 
					                    else -> return false
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                val c = when (curr) {
 | 
				
			||||||
 | 
					                    is ChatListItem.UserMessage -> curr.data.mine
 | 
				
			||||||
 | 
					                    is ChatListItem.AiMessage -> curr.data.mine
 | 
				
			||||||
 | 
					                    else -> return false
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                return p == c
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            val curr = list.getOrNull(position)
 | 
				
			||||||
 | 
					            val prev = if (position > 0) list.getOrNull(position - 1) else null
 | 
				
			||||||
 | 
					            val next = if (position < list.lastIndex) list.getOrNull(position + 1) else null
 | 
				
			||||||
 | 
					            val grouped = isSameSender(prev, curr)
 | 
				
			||||||
 | 
					            val isLast = !isSameSender(curr, next)
 | 
				
			||||||
 | 
					            return grouped to isLast
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private val items: MutableList<ChatListItem> = mutableListOf()
 | 
					    private val items: MutableList<ChatListItem> = mutableListOf()
 | 
				
			||||||
@@ -93,6 +122,15 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
        notifyDataSetChanged()
 | 
					        notifyDataSetChanged()
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * 테스트 전용: RecyclerView 프레임워크 없이 내부 리스트만 채우고 알림을 보내지 않는다.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    @VisibleForTesting
 | 
				
			||||||
 | 
					    internal fun setItemsForTest(newItems: List<ChatListItem>) {
 | 
				
			||||||
 | 
					        items.clear()
 | 
				
			||||||
 | 
					        items.addAll(newItems)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    fun addItem(item: ChatListItem) {
 | 
					    fun addItem(item: ChatListItem) {
 | 
				
			||||||
        items.add(item)
 | 
					        items.add(item)
 | 
				
			||||||
        notifyItemInserted(items.lastIndex)
 | 
					        notifyItemInserted(items.lastIndex)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,77 @@
 | 
				
			|||||||
 | 
					package kr.co.vividnext.sodalive.chat.talk.room
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import org.junit.Assert.assertEquals
 | 
				
			||||||
 | 
					import org.junit.Test
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ChatMessageAdapterTest {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Test
 | 
				
			||||||
 | 
					    fun `getItemViewType returns correct types`() {
 | 
				
			||||||
 | 
					        val adapter = ChatMessageAdapter()
 | 
				
			||||||
 | 
					        val list = listOf(
 | 
				
			||||||
 | 
					            ChatListItem.UserMessage(ChatMessage(1, "hi", "", mine = true, createdAt = 1L)),
 | 
				
			||||||
 | 
					            ChatListItem.AiMessage(ChatMessage(2, "hello", "", mine = false, createdAt = 2L)),
 | 
				
			||||||
 | 
					            ChatListItem.Notice("notice"),
 | 
				
			||||||
 | 
					            ChatListItem.TypingIndicator
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        adapter.setItemsForTest(list)
 | 
				
			||||||
 | 
					        assertEquals(ChatMessageAdapter.VIEW_TYPE_USER_MESSAGE, adapter.getItemViewType(0))
 | 
				
			||||||
 | 
					        assertEquals(ChatMessageAdapter.VIEW_TYPE_AI_MESSAGE, adapter.getItemViewType(1))
 | 
				
			||||||
 | 
					        assertEquals(ChatMessageAdapter.VIEW_TYPE_NOTICE, adapter.getItemViewType(2))
 | 
				
			||||||
 | 
					        assertEquals(ChatMessageAdapter.VIEW_TYPE_TYPING_INDICATOR, adapter.getItemViewType(3))
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Test
 | 
				
			||||||
 | 
					    fun `computeGroupingFlags determines group and last correctly`() {
 | 
				
			||||||
 | 
					        val items = listOf(
 | 
				
			||||||
 | 
					            ChatListItem.AiMessage(ChatMessage(1, "a1", "", mine = false, createdAt = 1L)),
 | 
				
			||||||
 | 
					            ChatListItem.AiMessage(ChatMessage(2, "a2", "", mine = false, createdAt = 2L)),
 | 
				
			||||||
 | 
					            ChatListItem.AiMessage(ChatMessage(3, "a3", "", mine = false, createdAt = 3L)),
 | 
				
			||||||
 | 
					            ChatListItem.UserMessage(ChatMessage(4, "u1", "", mine = true, createdAt = 4L)),
 | 
				
			||||||
 | 
					            ChatListItem.UserMessage(ChatMessage(5, "u2", "", mine = true, createdAt = 5L)),
 | 
				
			||||||
 | 
					            ChatListItem.Notice("guide"),
 | 
				
			||||||
 | 
					            ChatListItem.UserMessage(ChatMessage(6, "u3", "", mine = true, createdAt = 6L)),
 | 
				
			||||||
 | 
					            ChatListItem.AiMessage(ChatMessage(7, "a4", "", mine = false, createdAt = 7L))
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        // index 0: 첫 메시지, 그룹 아님, 다음과 같으므로 마지막 아님
 | 
				
			||||||
 | 
					        ChatMessageAdapter.computeGroupingFlags(items, 0).let { (grouped, last) ->
 | 
				
			||||||
 | 
					            assertEquals(false, grouped)
 | 
				
			||||||
 | 
					            assertEquals(false, last)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // index 1: 이전과 동일 발신자 -> 그룹, 다음과 동일 -> 마지막 아님
 | 
				
			||||||
 | 
					        ChatMessageAdapter.computeGroupingFlags(items, 1).let { (grouped, last) ->
 | 
				
			||||||
 | 
					            assertEquals(true, grouped)
 | 
				
			||||||
 | 
					            assertEquals(false, last)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // index 2: 이전과 동일 발신자 -> 그룹, 다음은 다른 발신자 -> 마지막
 | 
				
			||||||
 | 
					        ChatMessageAdapter.computeGroupingFlags(items, 2).let { (grouped, last) ->
 | 
				
			||||||
 | 
					            assertEquals(true, grouped)
 | 
				
			||||||
 | 
					            assertEquals(true, last)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // index 3: 사용자 메시지 시작 -> 그룹 아님, 다음 동일 -> 마지막 아님
 | 
				
			||||||
 | 
					        ChatMessageAdapter.computeGroupingFlags(items, 3).let { (grouped, last) ->
 | 
				
			||||||
 | 
					            assertEquals(false, grouped)
 | 
				
			||||||
 | 
					            assertEquals(false, last)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // index 4: 사용자 그룹의 마지막 (다음 아이템은 Notice라 발신자 비교 실패) -> grouped true, last true
 | 
				
			||||||
 | 
					        ChatMessageAdapter.computeGroupingFlags(items, 4).let { (grouped, last) ->
 | 
				
			||||||
 | 
					            assertEquals(true, grouped)
 | 
				
			||||||
 | 
					            assertEquals(true, last)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // index 5: Notice -> 그룹 로직 비대상, grouped false, last true(다음과 비교 불가로 true)
 | 
				
			||||||
 | 
					        ChatMessageAdapter.computeGroupingFlags(items, 5).let { (grouped, last) ->
 | 
				
			||||||
 | 
					            assertEquals(false, grouped)
 | 
				
			||||||
 | 
					            assertEquals(true, last)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // index 6: Notice 다음의 사용자 단독 -> grouped false, 다음은 ai라 last true
 | 
				
			||||||
 | 
					        ChatMessageAdapter.computeGroupingFlags(items, 6).let { (grouped, last) ->
 | 
				
			||||||
 | 
					            assertEquals(false, grouped)
 | 
				
			||||||
 | 
					            assertEquals(true, last)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // index 7: 마지막 ai -> grouped false, last true
 | 
				
			||||||
 | 
					        ChatMessageAdapter.computeGroupingFlags(items, 7).let { (grouped, last) ->
 | 
				
			||||||
 | 
					            assertEquals(false, grouped)
 | 
				
			||||||
 | 
					            assertEquals(true, last)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,67 @@
 | 
				
			|||||||
 | 
					package kr.co.vividnext.sodalive.chat.talk.room
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import io.mockk.coEvery
 | 
				
			||||||
 | 
					import io.mockk.coVerify
 | 
				
			||||||
 | 
					import io.mockk.every
 | 
				
			||||||
 | 
					import io.mockk.mockk
 | 
				
			||||||
 | 
					import io.reactivex.rxjava3.core.Single
 | 
				
			||||||
 | 
					import kr.co.vividnext.sodalive.chat.talk.TalkApi
 | 
				
			||||||
 | 
					import kr.co.vividnext.sodalive.chat.talk.room.db.ChatMessageDao
 | 
				
			||||||
 | 
					import kr.co.vividnext.sodalive.common.ApiResponse
 | 
				
			||||||
 | 
					import org.junit.Assert.assertEquals
 | 
				
			||||||
 | 
					import org.junit.Test
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ChatRepositoryTest {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Test
 | 
				
			||||||
 | 
					    fun `enterChatRoom inserts messages and returns response`() {
 | 
				
			||||||
 | 
					        val dao = mockk<ChatMessageDao>(relaxed = true)
 | 
				
			||||||
 | 
					        val api = mockk<TalkApi>()
 | 
				
			||||||
 | 
					        val repo = ChatRepository(dao, api)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        val serverMessages = listOf(
 | 
				
			||||||
 | 
					            ServerChatMessage(1, "a1", "", mine = false, createdAt = 1000L),
 | 
				
			||||||
 | 
					            ServerChatMessage(2, "u1", "", mine = true, createdAt = 2000L)
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        val character = CharacterInfo(10, "name", "", kr.co.vividnext.sodalive.chat.character.detail.CharacterType.CLONE)
 | 
				
			||||||
 | 
					        val resp = ChatRoomEnterResponse(99, character, serverMessages, hasMoreMessages = false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        every { api.enterChatRoom(any(), any()) } returns Single.just(ApiResponse(true, resp, null))
 | 
				
			||||||
 | 
					        coEvery { dao.getNthLatestCreatedAt(any(), any()) } returns null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        val result = repo.enterChatRoom("token", 99).blockingGet()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // 반환 검증
 | 
				
			||||||
 | 
					        assertEquals(99, result.roomId)
 | 
				
			||||||
 | 
					        assertEquals(2, result.messages.size)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // 로컬 저장 검증
 | 
				
			||||||
 | 
					        coVerify { dao.insertMessages(match { it.size == 2 }) }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @Test
 | 
				
			||||||
 | 
					    fun `getRecentMessagesFromLocal maps to domain`() {
 | 
				
			||||||
 | 
					        val dao = mockk<ChatMessageDao>()
 | 
				
			||||||
 | 
					        val api = mockk<TalkApi>()
 | 
				
			||||||
 | 
					        val repo = ChatRepository(dao, api)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        val entities = listOf(
 | 
				
			||||||
 | 
					            kr.co.vividnext.sodalive.chat.talk.room.db.ChatMessageEntity(
 | 
				
			||||||
 | 
					                messageId = 1L,
 | 
				
			||||||
 | 
					                roomId = 1L,
 | 
				
			||||||
 | 
					                message = "hello",
 | 
				
			||||||
 | 
					                profileImageUrl = "",
 | 
				
			||||||
 | 
					                mine = true,
 | 
				
			||||||
 | 
					                createdAt = 10L,
 | 
				
			||||||
 | 
					                status = MessageStatus.SENT,
 | 
				
			||||||
 | 
					                localId = null
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        coEvery { dao.getRecentMessages(1L) } returns entities
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        val list = repo.getRecentMessagesFromLocal(1L).blockingGet()
 | 
				
			||||||
 | 
					        assertEquals(1, list.size)
 | 
				
			||||||
 | 
					        assertEquals("hello", list[0].message)
 | 
				
			||||||
 | 
					        assertEquals(true, list[0].mine)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,18 @@
 | 
				
			|||||||
 | 
					package kr.co.vividnext.sodalive.chat.talk.room
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import org.junit.Assert.assertTrue
 | 
				
			||||||
 | 
					import org.junit.Test
 | 
				
			||||||
 | 
					import java.util.Locale
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TimeUtilsTest {
 | 
				
			||||||
 | 
					    @Test
 | 
				
			||||||
 | 
					    fun `formatMessageTime returns localized pattern for ko_KR`() {
 | 
				
			||||||
 | 
					        val ts = 1734190980000L // 임의 epoch millis
 | 
				
			||||||
 | 
					        val result = formatMessageTime(ts, Locale.KOREA)
 | 
				
			||||||
 | 
					        // 한국어 로케일일 때 "오전/오후 h:mm" 패턴 일부라도 충족하는지 확인
 | 
				
			||||||
 | 
					        assertTrue(result.isNotBlank())
 | 
				
			||||||
 | 
					        assertTrue(result.contains(":"))
 | 
				
			||||||
 | 
					        // 오전 또는 오후 텍스트 포함
 | 
				
			||||||
 | 
					        assertTrue(result.contains("오전") || result.contains("오후"))
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user