From d662bd0b65be8f774b7b7757bf62ce4b7e4a01ee Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 14 Aug 2025 18:08:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-ui):=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=ED=99=94,=20=EC=8B=9C=EA=B0=84=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=ED=8C=85,=20Repository=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 7 ++ .../chat/talk/room/ChatMessageAdapter.kt | 38 +++++++++ .../chat/talk/room/ChatMessageAdapterTest.kt | 77 +++++++++++++++++++ .../chat/talk/room/ChatRepositoryTest.kt | 67 ++++++++++++++++ .../sodalive/chat/talk/room/TimeUtilsTest.kt | 18 +++++ 5 files changed, 207 insertions(+) create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapterTest.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepositoryTest.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/TimeUtilsTest.kt diff --git a/app/build.gradle b/app/build.gradle index cd9ebfd5..0da44574 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt index 89b95fdb..7ee7b625 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt @@ -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() { 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, position: Int): Pair { + 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 = mutableListOf() @@ -93,6 +122,15 @@ class ChatMessageAdapter : RecyclerView.Adapter() { notifyDataSetChanged() } + /** + * 테스트 전용: RecyclerView 프레임워크 없이 내부 리스트만 채우고 알림을 보내지 않는다. + */ + @VisibleForTesting + internal fun setItemsForTest(newItems: List) { + items.clear() + items.addAll(newItems) + } + fun addItem(item: ChatListItem) { items.add(item) notifyItemInserted(items.lastIndex) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapterTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapterTest.kt new file mode 100644 index 00000000..2fed445b --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapterTest.kt @@ -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) + } + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepositoryTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepositoryTest.kt new file mode 100644 index 00000000..012f3783 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepositoryTest.kt @@ -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(relaxed = true) + val api = mockk() + 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() + val api = mockk() + 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) + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/TimeUtilsTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/TimeUtilsTest.kt new file mode 100644 index 00000000..fb1f0a30 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/chat/talk/room/TimeUtilsTest.kt @@ -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("오후")) + } +}