diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatMappers.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatMappers.kt new file mode 100644 index 00000000..e0a5622b --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatMappers.kt @@ -0,0 +1,38 @@ +package kr.co.vividnext.sodalive.v2.main.chat.dm.model + +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse + +private const val MESSAGE_TYPE_TEXT = "TEXT" + +fun DmChatMessageResponse.toUiItem(): DmChatMessageUiItem? { + if (!messageType.equals(MESSAGE_TYPE_TEXT, ignoreCase = true) || textMessage == null) return null + + return DmChatMessageUiItem( + messageId = messageId, + localId = null, + mine = mine, + textMessage = textMessage, + senderNickname = senderNickname, + senderProfileImageUrl = senderProfileImageUrl, + createdAt = createdAt, + status = DmChatMessageStatus.SENT + ) +} + +fun List.toUiItems(): List = mapNotNull { it.toUiItem() } + +/** Sorts messages by server creation time, then by messageId for same-time messages. */ +fun List.sortByCreatedAtAndMessageId(): List = + sortedWith(compareBy { it.createdAt }.thenBy { it.messageId ?: Long.MAX_VALUE }) + +/** Merges new messages while keeping the first-arrived item when server messageId duplicates. */ +fun List.mergeByMessageId( + incoming: List +): List { + val seenMessageIds = mapNotNull { it.messageId }.toMutableSet() + val newItems = incoming.filter { item -> + val messageId = item.messageId + messageId == null || seenMessageIds.add(messageId) + } + return (this + newItems).sortByCreatedAtAndMessageId() +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt new file mode 100644 index 00000000..302778ac --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.v2.main.chat.dm.model + +enum class DmChatMessageStatus { + SENDING, + SENT, + FAILED +} + +data class DmChatMessageUiItem( + val messageId: Long?, + val localId: String?, + val mine: Boolean, + val textMessage: String, + val senderNickname: String, + val senderProfileImageUrl: String, + val createdAt: Long, + val status: DmChatMessageStatus +) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatMapperTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatMapperTest.kt new file mode 100644 index 00000000..ad08b482 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatMapperTest.kt @@ -0,0 +1,97 @@ +package kr.co.vividnext.sodalive.v2.main.chat.dm + +import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageStatus +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.mergeByMessageId +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.sortByCreatedAtAndMessageId +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItem +import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItems +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DmChatMapperTest { + + @Test + fun `TEXT 메시지는 전송 완료 UI item으로 매핑된다`() { + val item = response(messageType = "TEXT", textMessage = "안녕하세요").toUiItem() + + requireNotNull(item) + assertEquals(10L, item.messageId) + assertNull(item.localId) + assertEquals(true, item.mine) + assertEquals("안녕하세요", item.textMessage) + assertEquals("크리에이터", item.senderNickname) + assertEquals("https://example.com/profile.png", item.senderProfileImageUrl) + assertEquals(1000L, item.createdAt) + assertEquals(DmChatMessageStatus.SENT, item.status) + } + + @Test + fun `messageType은 대소문자를 무시하고 TEXT로 매핑된다`() { + val item = response(messageType = "text", textMessage = "소문자 타입").toUiItem() + + requireNotNull(item) + assertEquals("소문자 타입", item.textMessage) + } + + @Test + fun `TEXT가 아니거나 textMessage가 null이면 UI item에서 제외된다`() { + val voice = response(messageType = "VOICE", textMessage = null, voiceMessageUrl = "https://example.com/voice.m4a") + val nullText = response(messageType = "TEXT", textMessage = null) + + assertNull(voice.toUiItem()) + assertNull(nullText.toUiItem()) + assertEquals(emptyList(), listOf(voice, nullText).toUiItems()) + } + + @Test + fun `메시지는 createdAt 이후 messageId 오름차순으로 정렬된다`() { + val items = listOf( + response(messageId = 3L, createdAt = 2000L), + response(messageId = 2L, createdAt = 1000L), + response(messageId = 1L, createdAt = 1000L) + ).toUiItems().sortByCreatedAtAndMessageId() + + assertEquals(listOf(1L, 2L, 3L), items.map { it.messageId }) + } + + @Test + fun `messageId가 중복된 서버 메시지는 병합 시 기존 item을 유지한다`() { + val existing = listOf( + response(messageId = 1L, textMessage = "기존").toUiItem(), + response(messageId = 2L, textMessage = "둘째").toUiItem() + ).filterNotNull() + val incoming = listOf( + response(messageId = 1L, textMessage = "중복 신규").toUiItem(), + response(messageId = 3L, textMessage = "셋째").toUiItem() + ).filterNotNull() + + val merged = existing.mergeByMessageId(incoming) + + assertEquals(listOf(1L, 2L, 3L), merged.map { it.messageId }) + assertEquals("기존", merged.first { it.messageId == 1L }.textMessage) + } + + private fun response( + messageId: Long = 10L, + messageType: String = "TEXT", + mine: Boolean = true, + createdAt: Long = 1000L, + textMessage: String? = "메시지", + voiceMessageUrl: String? = null, + senderId: Long = 20L, + senderNickname: String = "크리에이터", + senderProfileImageUrl: String = "https://example.com/profile.png" + ) = DmChatMessageResponse( + messageId = messageId, + messageType = messageType, + mine = mine, + createdAt = createdAt, + textMessage = textMessage, + voiceMessageUrl = voiceMessageUrl, + senderId = senderId, + senderNickname = senderNickname, + senderProfileImageUrl = senderProfileImageUrl + ) +}