feat(chat-ui): 메시지 그룹화, 시간 포맷팅, Repository 테스트 추가
This commit is contained in:
		@@ -209,4 +209,11 @@ dependencies {
 | 
			
		||||
    implementation "com.kakao.sdk:v2-user:2.21.0"
 | 
			
		||||
 | 
			
		||||
    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.widget.TextView
 | 
			
		||||
import androidx.annotation.LayoutRes
 | 
			
		||||
import androidx.annotation.VisibleForTesting
 | 
			
		||||
import androidx.core.view.isVisible
 | 
			
		||||
import androidx.recyclerview.widget.RecyclerView
 | 
			
		||||
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_NOTICE = 3
 | 
			
		||||
        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()
 | 
			
		||||
@@ -93,6 +122,15 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
			
		||||
        notifyDataSetChanged()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 테스트 전용: RecyclerView 프레임워크 없이 내부 리스트만 채우고 알림을 보내지 않는다.
 | 
			
		||||
     */
 | 
			
		||||
    @VisibleForTesting
 | 
			
		||||
    internal fun setItemsForTest(newItems: List<ChatListItem>) {
 | 
			
		||||
        items.clear()
 | 
			
		||||
        items.addAll(newItems)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun addItem(item: ChatListItem) {
 | 
			
		||||
        items.add(item)
 | 
			
		||||
        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