From f264aacfdb7d4eb64f8653f1c308e19c1ce0e407 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:56:55 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=ED=99=88=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B8=20=EC=B2=AB=20=EB=8C=80=ED=99=94=EC=9D=98=20?= =?UTF-8?q?=EC=88=9C=EC=B0=A8=20=EB=85=B8=EC=B6=9C=20=EB=94=9C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EA=B0=80=20=EC=82=AC=EB=9D=BC=EC=A7=80=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 홈에서 새 대화를 보내면 채팅방으로 넘어가며 ChatRoomViewModel이 서버 메시지를 통째로 재조회해 딜레이 없이 한번에 표시했다. ConversationSession(무스코프)이 갖고 있던 서버 응답을 PendingConversationReveal(싱글턴 우편함)에 담아 채팅방이 한 번 소비하게 하고, 그 자리에서 기존 순차 노출 로직을 그대로 타도록 했다. 소비 시 채팅방 쪽 요약·감정 상태도 함께 시드해 다음 전송 문맥이 비지 않게 하고, 소비되지 못한 값은 30초 뒤 만료시켜 나중에 다른 화면이 잘못 집어가지 않게 했다. 첫 댓글도 도착 즉시 붙지 않고 나머지와 동일하게 지연 후 노출되도록 바꾸고, 노출 간격 하한을 1초에서 0.5초로 낮췄다. --- .../conversation/CommentRevealPolicy.kt | 2 +- .../conversation/ConversationSession.kt | 19 +++ .../conversation/PendingConversationReveal.kt | 43 +++++++ .../conversation/ConversationSessionTest.kt | 73 ++++++++++- .../PendingConversationRevealTest.kt | 79 ++++++++++++ .../android/feature/chat/ChatRoomViewModel.kt | 18 ++- .../android/feature/chat/ChatRoomLoadTest.kt | 114 ++++++++++++++++++ .../feature/chat/ChatRoomRevealTest.kt | 35 ++++-- .../android/feature/chat/ChatRoomTestFakes.kt | 23 ++-- .../android/feature/home/HomeTestFakes.kt | 2 + 10 files changed, 384 insertions(+), 24 deletions(-) create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt create mode 100644 domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt create mode 100644 feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/CommentRevealPolicy.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/CommentRevealPolicy.kt index 8787922b..3f5202f7 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/CommentRevealPolicy.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/CommentRevealPolicy.kt @@ -2,7 +2,7 @@ package com.gamss.android.domain.conversation import kotlin.random.Random -const val COMMENT_REVEAL_MIN_GAP_MILLIS = 1_000L +const val COMMENT_REVEAL_MIN_GAP_MILLIS = 500L const val COMMENT_REVEAL_MAX_GAP_MILLIS = 3_000L fun nextCommentRevealGapMillis(random: Random = Random.Default): Long = diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt index 2f8c1bf9..7c976a0d 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt @@ -23,6 +23,7 @@ class ConversationSession @Inject constructor( private val createConversationCard: CreateConversationCardUseCase, private val summaryStore: ConversationSummaryStore, private val emotionAccumulator: ConversationEmotionAccumulator, + private val pendingReveal: PendingConversationReveal, ) { private val titleMutex = Mutex() @@ -75,11 +76,29 @@ class ConversationSession @Inject constructor( seed = result.data.message.content, ), ) + pendingReveal.save(result.data) } } return result } + /** + * 홈에서 새 대화를 열며 이미 받아온 첫 교환을 채팅방이 재조회 없이 그대로 이어받게 한다. + * 채팅방이 이 대화를 처음 여는 게 아니면(캐시가 비었거나 다른 conversationId면) null이라 + * 호출부는 [restore] 경로로 폴백한다. + * + * 홈의 send() 는 홈 쪽 인스턴스의 요약·감정 상태만 채워 둔다. 이 인스턴스는 그 첫 발화를 + * 한 번도 보지 못했으므로, [restore] 가 하는 것과 같은 방식으로 여기서도 시드해 둬야 + * 다음 전송의 문맥 요약과 대화 종료 시 감정 결과가 비어 있지 않다. + */ + suspend fun consumePendingReveal(conversationId: Long): SentMessage? { + val sent = pendingReveal.consume(conversationId) ?: return null + val utterances = listOf(sent.message).userUtterances() + summaryStore.restore(utterances) + emotionAccumulator.restore(utterances) + return sent + } + suspend fun finishSend() = coroutineScope { launch { assignPendingTitle() } launch { diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt new file mode 100644 index 00000000..66019cde --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt @@ -0,0 +1,43 @@ +package com.gamss.android.domain.conversation + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +const val PENDING_REVEAL_EXPIRY_MILLIS = 30_000L + +/** + * 홈에서 새 대화를 열며 받은 첫 교환을, 뒤이어 열리는 채팅방이 한 번 소비하도록 건네주는 우편함이다. + * + * [ConversationSession]은 대화별 감정·요약 상태를 들고 있어 무스코프라(홈과 채팅방이 각자 다른 + * 인스턴스를 받는다), 그 안에 캐시를 두면 홈 쪽 인스턴스에만 남고 채팅방 쪽으로 건너가지 않는다. + * 이 클래스는 최근 값 하나만 잠깐 들고 있다가 소비되면 비우는 우편함이라 인스턴스 간에 공유해도 + * 대화 상태가 섞일 일이 없어 [Singleton]으로 둔다. + * + * 홈→채팅방 이동이 중간에 끊기면(back 경쟁, 화면 destroy 등) 값이 소비되지 못한 채 남을 수 있다. + * 그 상태로 한참 뒤 같은 대화를 다시 열면(예: 목록에서) 오래된 첫 교환만 보여주고 서버의 실제 + * 전체 이력([restore])을 건너뛰게 된다. 정상적인 홈→채팅방 전환은 화면 전환 수준으로 즉시 + * 끝나므로, [PENDING_REVEAL_EXPIRY_MILLIS] 를 넉넉히 두고 그보다 오래된 값은 폐기해 [restore] 로 + * 폴백시킨다. + */ +@Singleton +class PendingConversationReveal @Inject constructor() { + + private val mutex = Mutex() + + private var pending: Entry? = null + + suspend fun save(sent: SentMessage, now: Long = System.currentTimeMillis()) { + mutex.withLock { pending = Entry(sent, now) } + } + + suspend fun consume(conversationId: Long, now: Long = System.currentTimeMillis()): SentMessage? = + mutex.withLock { + val current = pending?.takeIf { it.sent.message.conversationId == conversationId } ?: return@withLock null + pending = null + current.sent.takeIf { now - current.savedAtMillis <= PENDING_REVEAL_EXPIRY_MILLIS } + } + + private data class Entry(val sent: SentMessage, val savedAtMillis: Long) +} diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt index 21502d2c..d105583b 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt @@ -149,6 +149,68 @@ class ConversationSessionTest { assertEquals(1, repository.updatedTitles.size) } + @Test + fun 홈이_새_대화를_열면_다른_인스턴스인_채팅방도_reveal_캐시를_받는다() = runBlocking { + val repository = FakeConversationRepository() + val reveal = PendingConversationReveal() + val homeSession = session(repository, reveal) + val chatSession = session(repository, reveal) + + val result = homeSession.send(conversationId = null, content = SEED, replyToMessageId = null) + val sent = (result as AppResult.Success).data + + assertEquals(sent, chatSession.consumePendingReveal(ROOM_ID)) + } + + @Test + fun 채팅방이_reveal_캐시를_소비하면_요약_상태도_이어받는다() = runBlocking { + val repository = FakeConversationRepository() + val reveal = PendingConversationReveal() + val homeSession = session(repository, reveal) + val chatSession = session(repository, reveal) + + homeSession.send(conversationId = null, content = SEED, replyToMessageId = null) + chatSession.consumePendingReveal(ROOM_ID) + chatSession.send(conversationId = ROOM_ID, content = "팀장이 또 그랬어", replyToMessageId = null) + + assertEquals(listOf(null, SEED), repository.sentContextSummaries) + } + + @Test + fun reveal_캐시는_한_번_꺼내면_비워진다() = runBlocking { + val repository = FakeConversationRepository() + val reveal = PendingConversationReveal() + val homeSession = session(repository, reveal) + val chatSession = session(repository, reveal) + + homeSession.send(conversationId = null, content = SEED, replyToMessageId = null) + chatSession.consumePendingReveal(ROOM_ID) + + assertEquals(null, chatSession.consumePendingReveal(ROOM_ID)) + } + + @Test + fun 다른_대화_ID로는_reveal_캐시를_꺼내지_못한다() = runBlocking { + val repository = FakeConversationRepository() + val reveal = PendingConversationReveal() + val homeSession = session(repository, reveal) + val chatSession = session(repository, reveal) + + homeSession.send(conversationId = null, content = SEED, replyToMessageId = null) + + assertEquals(null, chatSession.consumePendingReveal(OTHER_ROOM_ID)) + } + + @Test + fun 이어_쓰는_대화는_reveal_캐시를_남기지_않는다() = runBlocking { + val repository = FakeConversationRepository() + val session = session(repository) + + session.continueWith(SEED) + + assertEquals(null, session.consumePendingReveal(ROOM_ID)) + } + @Test fun 제목으로_쓸_글자가_없으면_지정을_시도하지_않는다() = runBlocking { val repository = FakeConversationRepository() @@ -182,7 +244,15 @@ class ConversationSessionTest { finishSend() } - private fun session(repository: FakeConversationRepository) = ConversationSession( + /** + * 홈과 채팅방은 각자 다른 [ConversationSession] 인스턴스를 Hilt에서 주입받는다(무스코프). + * [pendingReveal]을 생략하면 새 인스턴스가 만들어져 서로 다른 화면처럼 격리되고, 같은 + * [PendingConversationReveal]을 넘기면 그 두 인스턴스가 캐시만 공유하는 실제 배선을 재현한다. + */ + private fun session( + repository: FakeConversationRepository, + pendingReveal: PendingConversationReveal = PendingConversationReveal(), + ) = ConversationSession( sendMessage = SendMessageUseCase(repository), getMessages = GetMessagesUseCase(repository), updateConversationTitle = UpdateConversationTitleUseCase(repository), @@ -196,6 +266,7 @@ class ConversationSessionTest { tokenCounter = CharLengthTokenCounter, ), emotionAccumulator = ConversationEmotionAccumulator(FlatClassifier), + pendingReveal = pendingReveal, ) private object PassThroughSummarizer : DiarySummarizer { diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt new file mode 100644 index 00000000..a471121e --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt @@ -0,0 +1,79 @@ +package com.gamss.android.domain.conversation + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PendingConversationRevealTest { + + @Test + fun 저장한_값을_같은_대화_ID로_꺼낼_수_있다() = runBlocking { + val reveal = PendingConversationReveal() + val sent = sentMessage() + + reveal.save(sent, now = BASE_TIME) + + assertEquals(sent, reveal.consume(ROOM_ID, now = BASE_TIME)) + } + + @Test + fun 한_번_꺼내면_다시는_꺼낼_수_없다() = runBlocking { + val reveal = PendingConversationReveal() + reveal.save(sentMessage(), now = BASE_TIME) + + reveal.consume(ROOM_ID, now = BASE_TIME) + + assertNull(reveal.consume(ROOM_ID, now = BASE_TIME)) + } + + @Test + fun 다른_대화_ID로는_꺼낼_수_없고_원래_값은_남는다() = runBlocking { + val reveal = PendingConversationReveal() + val sent = sentMessage() + reveal.save(sent, now = BASE_TIME) + + assertNull(reveal.consume(OTHER_ROOM_ID, now = BASE_TIME)) + assertEquals(sent, reveal.consume(ROOM_ID, now = BASE_TIME)) + } + + @Test + fun 만료_시간_이내면_꺼낼_수_있다() = runBlocking { + val reveal = PendingConversationReveal() + reveal.save(sentMessage(), now = BASE_TIME) + + val result = reveal.consume(ROOM_ID, now = BASE_TIME + PENDING_REVEAL_EXPIRY_MILLIS) + + assertEquals(ROOM_ID, result?.message?.conversationId) + } + + @Test + fun 만료_시간이_지나면_꺼내지_못하고_폐기된다() = runBlocking { + val reveal = PendingConversationReveal() + reveal.save(sentMessage(), now = BASE_TIME) + + val expired = reveal.consume(ROOM_ID, now = BASE_TIME + PENDING_REVEAL_EXPIRY_MILLIS + 1) + + assertNull(expired) + // 만료돼 버려졌으니 시간을 되돌려도 다시 꺼낼 수 없다 — 자리는 이미 비었다. + assertNull(reveal.consume(ROOM_ID, now = BASE_TIME)) + } + + private fun sentMessage() = SentMessage( + message = Message( + id = MESSAGE_ID, + conversationId = ROOM_ID, + sender = MessageSender.User, + content = "아 진짜 짜증나", + ), + commentStatus = CommentGenerationStatus.DONE, + comments = emptyList(), + ) + + private companion object { + const val ROOM_ID = 7L + const val OTHER_ROOM_ID = 8L + const val MESSAGE_ID = 100L + const val BASE_TIME = 1_700_000_000_000L + } +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt index e6dc3353..34170837 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt @@ -62,6 +62,20 @@ class ChatRoomViewModel @Inject constructor( private fun loadMessages(conversationId: Long) = intent { reduce { state.copy(conversationId = conversationId, isLoading = true) } + + val pending = session.consumePendingReveal(conversationId) + if (pending != null) { + reduce { + state.copy( + isLoading = false, + messages = listOf(pending.message), + pendingComments = pending.comments, + ) + } + launchCommentReveal() + return@intent + } + when (val result = session.restore(conversationId)) { is AppResult.Success -> reduce { state.copy(isLoading = false, messages = result.data, pendingComments = emptyList()) } @@ -136,8 +150,8 @@ class ChatRoomViewModel @Inject constructor( state.copy( isSending = false, conversationId = sent.message.conversationId, - messages = state.messages + sent.message + sent.comments.take(1), - pendingComments = sent.comments.drop(1), + messages = state.messages + sent.message, + pendingComments = sent.comments, input = if (state.input == sending.content) "" else state.input, replyTarget = state.replyTarget.takeIf { it?.messageId != sending.replyToMessageId }, ) diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt new file mode 100644 index 00000000..1f5ed219 --- /dev/null +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt @@ -0,0 +1,114 @@ +package com.gamss.android.feature.chat + +import com.gamss.android.domain.card.CreateCardUseCase +import com.gamss.android.domain.card.CreateConversationCardUseCase +import com.gamss.android.domain.conversation.ConversationSession +import com.gamss.android.domain.conversation.ConversationSummaryStore +import com.gamss.android.domain.conversation.EndConversationUseCase +import com.gamss.android.domain.conversation.GetMessagesUseCase +import com.gamss.android.domain.conversation.PendingConversationReveal +import com.gamss.android.domain.conversation.SendMessageUseCase +import com.gamss.android.domain.conversation.UpdateConversationTitleUseCase +import com.gamss.android.domain.emotion.ConversationEmotionAccumulator +import com.gamss.android.domain.summary.SummarizeDiaryUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.orbitmvi.orbit.test.test + +/** + * 홈과 채팅방은 각자 다른 [ConversationSession] 인스턴스를 받는다(무스코프 주입). 둘이 공유하는 + * 건 [PendingConversationReveal] 뿐이라, 여기서도 그 배선을 그대로 재현해 검증한다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatRoomLoadTest { + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun 홈에서_보낸_첫_교환은_채팅방_진입시_순차_노출된다() = runTest { + val repository = FakeConversationRepository(commentCount = 3) + val reveal = PendingConversationReveal() + val homeSession = homeSession(repository, reveal) + homeSession.send(conversationId = null, content = INPUT, replyToMessageId = null) + val viewModel = chatRoomViewModel(conversationRepository = repository, pendingReveal = reveal) + + viewModel.test(this) { + containerHost.start(ROOM_ID) + skipItems(1) // useChatEndFeature 반영 + skipItems(1) // isLoading = true + + val afterLoad = awaitState() + assertEquals(listOf(USER_ID), afterLoad.messages.map { it.id }) + assertEquals( + listOf(COMMENT_ID_BASE + 0, COMMENT_ID_BASE + 1, COMMENT_ID_BASE + 2), + afterLoad.pendingComments.map { it.id }, + ) + + val firstReveal = awaitState() + assertEquals(COMMENT_ID_BASE + 0, firstReveal.messages.last().id) + + val secondReveal = awaitState() + assertEquals(COMMENT_ID_BASE + 1, secondReveal.messages.last().id) + + val thirdReveal = awaitState() + assertEquals(COMMENT_ID_BASE + 2, thirdReveal.messages.last().id) + assertTrue(thirdReveal.pendingComments.isEmpty()) + + cancelAndIgnoreRemainingItems() + } + } + + @Test + fun reveal_캐시가_없으면_서버에서_다시_불러온다() = runTest { + val repository = FakeConversationRepository(commentCount = 3) + val viewModel = chatRoomViewModel(conversationRepository = repository) + + viewModel.test(this) { + containerHost.start(ROOM_ID) + skipItems(1) // useChatEndFeature 반영 + skipItems(1) // isLoading = true + + val afterLoad = awaitState() + assertTrue(afterLoad.messages.isEmpty()) + assertTrue(afterLoad.pendingComments.isEmpty()) + + cancelAndIgnoreRemainingItems() + } + } + + /** 홈 화면이 실제로 받는 것과 같은 모양의, 채팅방과는 별개인 ConversationSession 인스턴스. */ + private fun homeSession(repository: FakeConversationRepository, pendingReveal: PendingConversationReveal) = + ConversationSession( + sendMessage = SendMessageUseCase(repository), + getMessages = GetMessagesUseCase(repository), + updateConversationTitle = UpdateConversationTitleUseCase(repository), + endConversation = EndConversationUseCase(repository), + createConversationCard = CreateConversationCardUseCase( + summarizeDiary = SummarizeDiaryUseCase(PassThroughSummarizer), + createCard = CreateCardUseCase(CountingCardRepository()), + ), + summaryStore = ConversationSummaryStore( + summarizer = PassThroughSummarizer, + tokenCounter = CharLengthTokenCounter, + ), + emotionAccumulator = ConversationEmotionAccumulator(FlatClassifier), + pendingReveal = pendingReveal, + ) +} diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt index 3b7403af..985f0c9c 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt @@ -29,7 +29,7 @@ class ChatRoomRevealTest { } @Test - fun 첫_댓글만_즉시_붙고_나머지는_하나씩_노출된다() = runTest { + fun 댓글은_첫_번째부터_하나씩_지연되어_노출된다() = runTest { val viewModel = viewModel(commentCount = 3) viewModel.test(this) { @@ -40,18 +40,25 @@ class ChatRoomRevealTest { skipItems(1) // isSending = true val afterSend = awaitState() - assertEquals(listOf(USER_ID, COMMENT_ID_BASE + 0), afterSend.messages.map { it.id }) - assertEquals(listOf(COMMENT_ID_BASE + 1, COMMENT_ID_BASE + 2), afterSend.pendingComments.map { it.id }) + assertEquals(listOf(USER_ID), afterSend.messages.map { it.id }) + assertEquals( + listOf(COMMENT_ID_BASE + 0, COMMENT_ID_BASE + 1, COMMENT_ID_BASE + 2), + afterSend.pendingComments.map { it.id }, + ) assertTrue(afterSend.isAwaitingComments) val firstReveal = awaitState() - assertEquals(COMMENT_ID_BASE + 1, firstReveal.messages.last().id) - assertEquals(1, firstReveal.pendingComments.size) + assertEquals(COMMENT_ID_BASE + 0, firstReveal.messages.last().id) + assertEquals(2, firstReveal.pendingComments.size) val secondReveal = awaitState() - assertEquals(COMMENT_ID_BASE + 2, secondReveal.messages.last().id) - assertTrue(secondReveal.pendingComments.isEmpty()) - assertFalse(secondReveal.isAwaitingComments) + assertEquals(COMMENT_ID_BASE + 1, secondReveal.messages.last().id) + assertEquals(1, secondReveal.pendingComments.size) + + val thirdReveal = awaitState() + assertEquals(COMMENT_ID_BASE + 2, thirdReveal.messages.last().id) + assertTrue(thirdReveal.pendingComments.isEmpty()) + assertFalse(thirdReveal.isAwaitingComments) cancelAndIgnoreRemainingItems() } @@ -68,7 +75,7 @@ class ChatRoomRevealTest { skipItems(1) // isSending = true val afterSend = awaitState() - assertEquals(3, afterSend.pendingComments.size) + assertEquals(4, afterSend.pendingComments.size) containerHost.onInputChange(INPUT) skipItems(1) // input 반영 @@ -83,7 +90,7 @@ class ChatRoomRevealTest { } @Test - fun 댓글이_하나면_노출_대기가_없다() = runTest { + fun 댓글이_하나여도_지연_후_노출된다() = runTest { val viewModel = viewModel(commentCount = 1) viewModel.test(this) { @@ -93,8 +100,12 @@ class ChatRoomRevealTest { skipItems(1) // isSending = true val afterSend = awaitState() - assertTrue(afterSend.pendingComments.isEmpty()) - assertEquals(2, afterSend.messages.size) + assertEquals(1, afterSend.pendingComments.size) + assertEquals(1, afterSend.messages.size) + + val afterReveal = awaitState() + assertTrue(afterReveal.pendingComments.isEmpty()) + assertEquals(2, afterReveal.messages.size) cancelAndIgnoreRemainingItems() } diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt index 64323f47..0271bd39 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt @@ -17,6 +17,7 @@ import com.gamss.android.domain.conversation.EndConversationUseCase import com.gamss.android.domain.conversation.GetMessagesUseCase import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender +import com.gamss.android.domain.conversation.PendingConversationReveal import com.gamss.android.domain.conversation.SendMessageUseCase import com.gamss.android.domain.conversation.SentMessage import com.gamss.android.domain.conversation.UpdateConversationTitleUseCase @@ -51,14 +52,11 @@ internal fun chatRoomViewModel( classifier: EmotionClassifier = FlatClassifier, tokenUsageRefreshNotifier: TokenUsageRefreshNotifier = RecordingTokenUsageRefreshNotifier(), remoteConfigRepository: RemoteConfigRepository = FakeRemoteConfigRepository(), -): ChatRoomViewModel = ChatRoomViewModel( - tokenUsageRefreshNotifier = tokenUsageRefreshNotifier, - detectRiskInText = DetectRiskInTextUseCase( - repository = NoRiskLexiconRepository, - matcher = RiskTermMatcher(), - ), - getRemoteConfigFlag = GetRemoteConfigFlagUseCase(remoteConfigRepository), - session = ConversationSession( + // 실제 앱에서 채팅방이 받는 ConversationSession은 홈의 것과 다른 인스턴스다(무스코프 주입). + // 홈에서 먼저 보낸 시나리오를 검증하려면, 호출부가 홈 쪽 세션과 이 PendingConversationReveal + // 인스턴스만 공유해서 넘긴다 — 나머지 상태(요약·감정 누적)는 공유하지 않아야 실제 배선과 같다. + pendingReveal: PendingConversationReveal = PendingConversationReveal(), + session: ConversationSession = ConversationSession( sendMessage = SendMessageUseCase(conversationRepository), getMessages = GetMessagesUseCase(conversationRepository), updateConversationTitle = UpdateConversationTitleUseCase(conversationRepository), @@ -72,7 +70,16 @@ internal fun chatRoomViewModel( tokenCounter = CharLengthTokenCounter, ), emotionAccumulator = ConversationEmotionAccumulator(classifier), + pendingReveal = pendingReveal, ), +): ChatRoomViewModel = ChatRoomViewModel( + tokenUsageRefreshNotifier = tokenUsageRefreshNotifier, + detectRiskInText = DetectRiskInTextUseCase( + repository = NoRiskLexiconRepository, + matcher = RiskTermMatcher(), + ), + getRemoteConfigFlag = GetRemoteConfigFlagUseCase(remoteConfigRepository), + session = session, ) /** 원격 설정 조회 없이 항상 켜진 값을 돌려준다. 값 자체를 검증하는 테스트는 별도로 stub 한다. */ diff --git a/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt b/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt index ca5c12e6..17fcdf88 100644 --- a/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt +++ b/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt @@ -14,6 +14,7 @@ import com.gamss.android.domain.conversation.EndConversationUseCase import com.gamss.android.domain.conversation.GetMessagesUseCase import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender +import com.gamss.android.domain.conversation.PendingConversationReveal import com.gamss.android.domain.conversation.SendMessageUseCase import com.gamss.android.domain.conversation.SentMessage import com.gamss.android.domain.conversation.UpdateConversationTitleUseCase @@ -43,6 +44,7 @@ internal fun conversationSession(repository: ConversationRepository) = Conversat tokenCounter = CharLengthTokenCounter, ), emotionAccumulator = ConversationEmotionAccumulator(FlatClassifier), + pendingReveal = PendingConversationReveal(), ) /** From dbc0f8e1070034ff066828b4d9bc48dc51303720 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:02:20 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20=EA=B3=BC=ED=95=9C=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/conversation/ConversationSession.kt | 10 +++------- .../conversation/PendingConversationReveal.kt | 13 ++----------- .../gamss/android/feature/chat/ChatRoomTestFakes.kt | 3 --- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt index 7c976a0d..d96b823f 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt @@ -83,13 +83,9 @@ class ConversationSession @Inject constructor( } /** - * 홈에서 새 대화를 열며 이미 받아온 첫 교환을 채팅방이 재조회 없이 그대로 이어받게 한다. - * 채팅방이 이 대화를 처음 여는 게 아니면(캐시가 비었거나 다른 conversationId면) null이라 - * 호출부는 [restore] 경로로 폴백한다. - * - * 홈의 send() 는 홈 쪽 인스턴스의 요약·감정 상태만 채워 둔다. 이 인스턴스는 그 첫 발화를 - * 한 번도 보지 못했으므로, [restore] 가 하는 것과 같은 방식으로 여기서도 시드해 둬야 - * 다음 전송의 문맥 요약과 대화 종료 시 감정 결과가 비어 있지 않다. + * 홈에서 이미 받아온 첫 교환을 채팅방이 재조회 없이 이어받게 한다. 캐시가 없으면(비었거나 + * 다른 conversationId면) null이라 호출부는 [restore] 로 폴백한다. 홈 쪽 인스턴스에서만 + * 채워진 요약·감정 상태를 [restore] 와 같은 방식으로 이 인스턴스에도 시드해 둔다. */ suspend fun consumePendingReveal(conversationId: Long): SentMessage? { val sent = pendingReveal.consume(conversationId) ?: return null diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt index 66019cde..b54b9b52 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt @@ -9,17 +9,8 @@ const val PENDING_REVEAL_EXPIRY_MILLIS = 30_000L /** * 홈에서 새 대화를 열며 받은 첫 교환을, 뒤이어 열리는 채팅방이 한 번 소비하도록 건네주는 우편함이다. - * - * [ConversationSession]은 대화별 감정·요약 상태를 들고 있어 무스코프라(홈과 채팅방이 각자 다른 - * 인스턴스를 받는다), 그 안에 캐시를 두면 홈 쪽 인스턴스에만 남고 채팅방 쪽으로 건너가지 않는다. - * 이 클래스는 최근 값 하나만 잠깐 들고 있다가 소비되면 비우는 우편함이라 인스턴스 간에 공유해도 - * 대화 상태가 섞일 일이 없어 [Singleton]으로 둔다. - * - * 홈→채팅방 이동이 중간에 끊기면(back 경쟁, 화면 destroy 등) 값이 소비되지 못한 채 남을 수 있다. - * 그 상태로 한참 뒤 같은 대화를 다시 열면(예: 목록에서) 오래된 첫 교환만 보여주고 서버의 실제 - * 전체 이력([restore])을 건너뛰게 된다. 정상적인 홈→채팅방 전환은 화면 전환 수준으로 즉시 - * 끝나므로, [PENDING_REVEAL_EXPIRY_MILLIS] 를 넉넉히 두고 그보다 오래된 값은 폐기해 [restore] 로 - * 폴백시킨다. + * [ConversationSession]은 무스코프라 홈과 채팅방이 서로 다른 인스턴스를 받으므로 [Singleton]으로 둔다. + * 소비되지 못한 값은 [PENDING_REVEAL_EXPIRY_MILLIS] 뒤 만료시켜 [restore] 로 폴백하게 한다. */ @Singleton class PendingConversationReveal @Inject constructor() { diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt index 0271bd39..9d451cc6 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt @@ -52,9 +52,6 @@ internal fun chatRoomViewModel( classifier: EmotionClassifier = FlatClassifier, tokenUsageRefreshNotifier: TokenUsageRefreshNotifier = RecordingTokenUsageRefreshNotifier(), remoteConfigRepository: RemoteConfigRepository = FakeRemoteConfigRepository(), - // 실제 앱에서 채팅방이 받는 ConversationSession은 홈의 것과 다른 인스턴스다(무스코프 주입). - // 홈에서 먼저 보낸 시나리오를 검증하려면, 호출부가 홈 쪽 세션과 이 PendingConversationReveal - // 인스턴스만 공유해서 넘긴다 — 나머지 상태(요약·감정 누적)는 공유하지 않아야 실제 배선과 같다. pendingReveal: PendingConversationReveal = PendingConversationReveal(), session: ConversationSession = ConversationSession( sendMessage = SendMessageUseCase(conversationRepository), From 2a1d7fac9de8a065e3bc7968bb25a3dc0535d7d5 Mon Sep 17 00:00:00 2001 From: soyeonLee <109227292+soyeonLee126@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:04:19 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=ED=98=95=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/domain/conversation/ConversationSession.kt | 6 +----- .../domain/conversation/PendingConversationReveal.kt | 5 ----- .../java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt | 4 ---- 3 files changed, 1 insertion(+), 14 deletions(-) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt index d96b823f..cda89e1c 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt @@ -82,11 +82,7 @@ class ConversationSession @Inject constructor( return result } - /** - * 홈에서 이미 받아온 첫 교환을 채팅방이 재조회 없이 이어받게 한다. 캐시가 없으면(비었거나 - * 다른 conversationId면) null이라 호출부는 [restore] 로 폴백한다. 홈 쪽 인스턴스에서만 - * 채워진 요약·감정 상태를 [restore] 와 같은 방식으로 이 인스턴스에도 시드해 둔다. - */ + /** 홈 쪽 인스턴스에서만 채워진 요약·감정 상태를, [restore] 와 같은 방식으로 이 인스턴스에도 시드해 둔다. */ suspend fun consumePendingReveal(conversationId: Long): SentMessage? { val sent = pendingReveal.consume(conversationId) ?: return null val utterances = listOf(sent.message).userUtterances() diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt index b54b9b52..9e555a5d 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt @@ -7,11 +7,6 @@ import javax.inject.Singleton const val PENDING_REVEAL_EXPIRY_MILLIS = 30_000L -/** - * 홈에서 새 대화를 열며 받은 첫 교환을, 뒤이어 열리는 채팅방이 한 번 소비하도록 건네주는 우편함이다. - * [ConversationSession]은 무스코프라 홈과 채팅방이 서로 다른 인스턴스를 받으므로 [Singleton]으로 둔다. - * 소비되지 못한 값은 [PENDING_REVEAL_EXPIRY_MILLIS] 뒤 만료시켜 [restore] 로 폴백하게 한다. - */ @Singleton class PendingConversationReveal @Inject constructor() { diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt index 1f5ed219..e671cd9f 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt @@ -24,10 +24,6 @@ import org.junit.Before import org.junit.Test import org.orbitmvi.orbit.test.test -/** - * 홈과 채팅방은 각자 다른 [ConversationSession] 인스턴스를 받는다(무스코프 주입). 둘이 공유하는 - * 건 [PendingConversationReveal] 뿐이라, 여기서도 그 배선을 그대로 재현해 검증한다. - */ @OptIn(ExperimentalCoroutinesApi::class) class ChatRoomLoadTest {