1@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
3package soy.iko.opencode.ui.chat
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.ExperimentalCoroutinesApi
7import kotlinx.coroutines.launch
8import kotlinx.coroutines.flow.first
9import kotlinx.coroutines.flow.toList
10import kotlinx.coroutines.test.StandardTestDispatcher
11import kotlinx.coroutines.test.TestScope
12import kotlinx.coroutines.test.resetMain
13import kotlinx.coroutines.test.runTest
14import kotlinx.coroutines.test.setMain
16import org.junit.Assert.assertEquals
17import org.junit.Assert.assertFalse
18import org.junit.Assert.assertNotNull
19import org.junit.Assert.assertNull
20import org.junit.Assert.assertTrue
21import org.junit.Before
23import soy.iko.opencode.data.model.Command
24import soy.iko.opencode.data.model.MessageWithParts
25import soy.iko.opencode.data.model.ModelInfo
26import soy.iko.opencode.data.model.Permission
27import soy.iko.opencode.data.model.PermissionResponse
28import soy.iko.opencode.data.model.PermissionUpdated
29import soy.iko.opencode.data.model.Provider
30import soy.iko.opencode.data.model.ProvidersResponse
31import soy.iko.opencode.data.model.ServerProfile
32import soy.iko.opencode.data.model.Session
33import soy.iko.opencode.data.model.SessionDeleted
34import soy.iko.opencode.data.model.SessionError
35import soy.iko.opencode.data.model.SessionIdle
36import soy.iko.opencode.data.model.SessionUpdated
37import soy.iko.opencode.data.model.UserMessage
38import soy.iko.opencode.data.network.EventStreamClient
39import soy.iko.opencode.ui.testing.FakeAppContainer
40import soy.iko.opencode.ui.testing.FakeEventStreamClient
41import soy.iko.opencode.ui.testing.FakeOpencodeApiClient
42import soy.iko.opencode.ui.testing.FakeOpencodeConnection
43import soy.iko.opencode.ui.testing.FakeSessionRepository
44import java.io.IOException
46class ChatViewModelTest {
47 private val testDispatcher = StandardTestDispatcher()
48 private val testScope = TestScope(testDispatcher)
52 Dispatchers.setMain(testDispatcher)
57 Dispatchers.resetMain()
60 private fun makeContainer(
61 api: FakeOpencodeApiClient = FakeOpencodeApiClient(),
62 events: FakeEventStreamClient = FakeEventStreamClient(),
63 repo: FakeSessionRepository = FakeSessionRepository(api, events),
64 profile: ServerProfile = ServerProfile(id = "s1", label = "Test", baseUrl = "http://localhost"),
66 val container = FakeAppContainer()
67 val conn = FakeOpencodeConnection(api, events, repo, profile)
68 container.setActiveConnection(conn)
72 /** Creates a ViewModel and lets its init coroutines start before returning. */
73 private fun makeVm(container: FakeAppContainer, sessionId: String = "session1"): ChatViewModel {
74 val vm = ChatViewModel(container, sessionId)
75 testScope.testScheduler.advanceUntilIdle()
82 fun send_queuesToOutboxWhenNotConnected() = testScope.runTest {
83 // The offline-outbox feature changed send()'s contract: when there's no active
84 // connection the prompt is accepted for offline queueing (returns true) rather than
85 // being rejected. (This test previously asserted the old "returns false" behavior.)
86 val container = FakeAppContainer()
87 val vm = makeVm(container)
88 assertTrue(vm.send("hello"))
92 fun send_returnsFalseForEmptyText() = testScope.runTest {
93 val container = makeContainer()
94 val vm = makeVm(container)
95 assertFalse(vm.send(""))
96 assertFalse(vm.send(" "))
100 fun send_returnsFalseWhenAlreadyRunning() = testScope.runTest {
101 val container = makeContainer()
102 val vm = makeVm(container)
103 assertTrue(vm.send("first"))
104 assertFalse(vm.send("second"))
108 fun send_clearsDraftImmediatelyAndSetsRunning() = testScope.runTest {
109 val container = makeContainer()
110 val vm = makeVm(container)
111 vm.updateDraft("hello world")
112 assertTrue(vm.send("hello world"))
113 assertEquals("", vm.draft.value)
114 assertTrue(vm.running.value)
118 fun send_successPersistsEmptyDraft() = testScope.runTest {
119 val container = makeContainer()
120 val vm = makeVm(container)
121 vm.updateDraft("hello")
122 assertTrue(vm.send("hello"))
123 testScope.testScheduler.advanceUntilIdle()
124 assertEquals("", container.fakeDraftStore.get("session1"))
125 assertNull(vm.failedDraft.value)
129 fun send_failureRestoresDraftAndClearsRunning() = testScope.runTest {
130 val api = FakeOpencodeApiClient()
131 val events = FakeEventStreamClient()
132 val repo = FakeSessionRepository(api, events)
133 repo.sendPromptThrows = IOException("network error")
134 val container = makeContainer(api = api, events = events, repo = repo)
135 val vm = makeVm(container)
136 vm.updateDraft("hello")
137 assertTrue(vm.send("hello"))
138 testScope.testScheduler.advanceUntilIdle()
139 assertEquals("hello", vm.draft.value)
140 assertEquals("hello", vm.failedDraft.value)
141 assertFalse(vm.running.value)
142 val errors = mutableListOf<ChatError>()
143 val collectJob = launch { vm.errorEvents.toList(errors) }
144 assertTrue(vm.send("hello"))
145 testScheduler.advanceUntilIdle()
147 assertTrue(errors.isNotEmpty())
151 fun send_failureDoesNotRestoreDraftIfUserTypedNewText() = testScope.runTest {
152 val api = FakeOpencodeApiClient()
153 val events = FakeEventStreamClient()
154 val repo = FakeSessionRepository(api, events)
155 repo.sendPromptThrows = IOException("network error")
156 val container = makeContainer(api = api, events = events, repo = repo)
157 val vm = makeVm(container)
158 vm.updateDraft("original")
159 assertTrue(vm.send("original"))
160 vm.updateDraft("new text")
161 testScope.testScheduler.advanceUntilIdle()
162 assertEquals("new text", vm.draft.value)
165 // --- retryFailed() ---
168 fun retryFailed_noopWhenNoFailedDraft() = testScope.runTest {
169 val container = makeContainer()
170 val vm = makeVm(container)
172 testScope.testScheduler.advanceUntilIdle()
173 assertFalse(vm.running.value)
177 fun retryFailed_resendsFailedDraft() = testScope.runTest {
178 val api = FakeOpencodeApiClient()
179 val events = FakeEventStreamClient()
180 val repo = FakeSessionRepository(api, events)
181 repo.sendPromptThrows = IOException("network error")
182 val container = makeContainer(api = api, events = events, repo = repo)
183 val vm = makeVm(container)
184 vm.updateDraft("hello")
185 assertTrue(vm.send("hello"))
186 testScope.testScheduler.advanceUntilIdle()
187 assertEquals("hello", vm.failedDraft.value)
189 repo.sendPromptThrows = null
191 testScope.testScheduler.advanceUntilIdle()
192 assertNull(vm.failedDraft.value)
195 // --- runCommand() ---
198 fun runCommand_setsRunning() = testScope.runTest {
199 val container = makeContainer()
200 val vm = makeVm(container)
201 vm.runCommand(Command(name = "compact"))
202 assertTrue(vm.running.value)
206 fun runCommand_rejectedWhenAlreadyRunning() = testScope.runTest {
207 val container = makeContainer()
208 val vm = makeVm(container)
209 assertTrue(vm.send("hello"))
210 assertFalse(vm.running.value.not()) // running is true
211 vm.runCommand(Command(name = "compact"))
212 // Still running from the first send; runCommand should not override
213 assertTrue(vm.running.value)
219 fun abort_clearsRunning() = testScope.runTest {
220 val container = makeContainer()
221 val vm = makeVm(container)
222 assertTrue(vm.send("hello"))
223 testScope.testScheduler.advanceUntilIdle()
225 testScope.testScheduler.advanceUntilIdle()
226 assertFalse(vm.running.value)
230 fun abort_emitsStopUndoWithLastPrompt() = testScope.runTest {
231 val container = makeContainer()
232 val vm = makeVm(container)
233 assertTrue(vm.send("hello there"))
234 testScope.testScheduler.advanceUntilIdle()
236 testScope.testScheduler.advanceUntilIdle()
237 // The undo event carries the last-sent prompt so the UI can show "Stopped — Undo".
238 val prompt = vm.stopUndoEvents.first()
239 assertEquals("hello there", prompt)
243 fun resendLastPrompt_reSendsTheTrackedPrompt() = testScope.runTest {
244 val container = makeContainer()
245 val vm = makeVm(container)
246 assertTrue(vm.send("original prompt"))
247 testScope.testScheduler.advanceUntilIdle()
248 // Abort surfaces the undo event; resendLastPrompt re-sends the tracked text.
250 testScope.testScheduler.advanceUntilIdle()
251 vm.resendLastPrompt()
252 testScope.testScheduler.advanceUntilIdle()
253 // The send should have re-queued the prompt (running re-lights).
254 assertTrue(vm.running.value)
257 // --- respondPermission() ---
260 fun respondPermission_clearsPendingOptimistically() = testScope.runTest {
261 val events = FakeEventStreamClient()
262 val container = makeContainer(events = events)
263 val vm = makeVm(container)
264 val perm = Permission(id = "p1", sessionID = "session1")
266 events.fakeEvents.tryEmit(PermissionUpdated(perm))
267 testScope.testScheduler.advanceUntilIdle()
268 assertEquals("p1", vm.pendingPermission.value?.id)
270 vm.respondPermission(perm, PermissionResponse.ONCE)
271 assertNull(vm.pendingPermission.value)
275 fun respondPermission_restoresPendingOnFailure() = testScope.runTest {
276 val api = FakeOpencodeApiClient()
277 api.respondPermissionThrows = IOException("network error")
278 val container = makeContainer(api = api)
279 val vm = makeVm(container)
280 val perm = Permission(id = "p1", sessionID = "session1")
282 val events = (container.activeConnection.value!!.events as FakeEventStreamClient)
283 events.fakeEvents.tryEmit(PermissionUpdated(perm))
284 testScope.testScheduler.advanceUntilIdle()
285 assertEquals("p1", vm.pendingPermission.value?.id)
287 val errors = mutableListOf<ChatError>()
288 val collectJob = launch { vm.errorEvents.toList(errors) }
289 vm.respondPermission(perm, PermissionResponse.ONCE)
290 assertNull(vm.pendingPermission.value)
291 testScope.testScheduler.advanceUntilIdle()
293 assertEquals("p1", vm.pendingPermission.value?.id)
294 assertTrue(errors.isNotEmpty())
297 // --- SSE event handling ---
300 fun sseSessionIdle_resetsRunning() = testScope.runTest {
301 val events = FakeEventStreamClient()
302 val container = makeContainer(events = events)
303 val vm = makeVm(container)
304 assertTrue(vm.send("hello"))
305 testScope.testScheduler.advanceUntilIdle()
306 events.fakeEvents.tryEmit(SessionIdle(SessionIdle.Props(sessionID = "session1")))
307 testScope.testScheduler.advanceUntilIdle()
308 assertFalse(vm.running.value)
312 fun sseSessionError_resetsRunningAndEmitsError() = testScope.runTest {
313 val events = FakeEventStreamClient()
314 val container = makeContainer(events = events)
315 val vm = makeVm(container)
316 val errors = mutableListOf<ChatError>()
317 val collectJob = launch { vm.errorEvents.toList(errors) }
318 assertTrue(vm.send("hello"))
319 testScope.testScheduler.advanceUntilIdle()
320 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
321 testScope.testScheduler.advanceUntilIdle()
323 assertFalse(vm.running.value)
324 assertTrue(errors.isNotEmpty())
328 fun sseSessionIdle_ignoresOtherSession() = testScope.runTest {
329 val events = FakeEventStreamClient()
330 val container = makeContainer(events = events)
331 val vm = makeVm(container)
332 assertTrue(vm.send("hello"))
333 testScope.testScheduler.advanceUntilIdle()
334 events.fakeEvents.tryEmit(SessionIdle(SessionIdle.Props(sessionID = "other")))
335 testScope.testScheduler.advanceUntilIdle()
336 assertTrue(vm.running.value)
340 fun sseSessionUpdated_updatesTitle() = testScope.runTest {
341 val events = FakeEventStreamClient()
342 val container = makeContainer(events = events)
343 val vm = makeVm(container)
344 events.fakeEvents.tryEmit(
345 SessionUpdated(SessionUpdated.Props(info = Session(id = "session1", title = "My Chat"))),
347 testScope.testScheduler.advanceUntilIdle()
348 assertEquals("My Chat", vm.sessionTitle.value)
352 fun sseSessionDeleted_setsDeletedFlag() = testScope.runTest {
353 val events = FakeEventStreamClient()
354 val container = makeContainer(events = events)
355 val vm = makeVm(container)
356 events.fakeEvents.tryEmit(
357 SessionDeleted(SessionDeleted.Props(sessionID = "session1")),
359 testScope.testScheduler.advanceUntilIdle()
360 assertTrue(vm.sessionDeleted.value)
363 // --- Connection state ---
366 fun sseDisconnected_keepsRunningAndFlagsInterrupted() = testScope.runTest {
367 val events = FakeEventStreamClient()
368 val container = makeContainer(events = events)
369 val vm = makeVm(container)
370 assertTrue(vm.send("hello"))
371 testScope.testScheduler.advanceUntilIdle()
372 events.fakeState.value = EventStreamClient.ConnectionState.Disconnected
373 testScope.testScheduler.advanceUntilIdle()
374 // A transient SSE drop does NOT clear _running — the run is still active on the
375 // server, and showing it as finished would mislead the user. Instead the stream-
376 // interrupted flag is set so the working row shows a "reconnecting" visual.
377 assertTrue(vm.running.value)
378 assertTrue(vm.streamInterrupted.value)
379 // Reconnecting clears the interrupted flag but keeps running.
380 events.fakeState.value = EventStreamClient.ConnectionState.Connected
381 testScope.testScheduler.advanceUntilIdle()
382 assertTrue(vm.running.value)
383 assertFalse(vm.streamInterrupted.value)
387 fun sseFailed_resetsRunning() = testScope.runTest {
388 val events = FakeEventStreamClient()
389 val container = makeContainer(events = events)
390 val vm = makeVm(container)
391 assertTrue(vm.send("hello"))
392 testScope.testScheduler.advanceUntilIdle()
393 events.fakeState.value = EventStreamClient.ConnectionState.Failed
394 testScope.testScheduler.advanceUntilIdle()
395 assertFalse(vm.running.value)
398 // --- Draft persistence ---
401 fun updateDraft_doesNotPersistImmediately() = testScope.runTest {
402 val container = makeContainer()
403 val vm = makeVm(container)
404 vm.updateDraft("typing")
405 assertEquals("", container.fakeDraftStore.get("session1"))
406 assertEquals("typing", vm.draft.value)
410 fun updateDraft_persistsAfterDebounce() = testScope.runTest {
411 val container = makeContainer()
412 val vm = makeVm(container)
413 vm.updateDraft("typing")
414 testScope.testScheduler.advanceUntilIdle()
415 assertEquals("typing", container.fakeDraftStore.get("session1"))
419 fun onCleared_flushesPendingDraft() = testScope.runTest {
420 val container = makeContainer()
421 val vm = makeVm(container)
422 vm.updateDraft("unsent draft")
423 testScope.testScheduler.advanceUntilIdle()
424 // onCleared() is protected — invoke via reflection
425 val method = androidx.lifecycle.ViewModel::class.java.getDeclaredMethod("onCleared")
426 method.isAccessible = true
428 assertEquals("unsent draft", container.fakeDraftStore.lastFlushed?.second)
431 // --- Catalog loading ---
434 fun catalogs_loadFromApi() = testScope.runTest {
435 val api = FakeOpencodeApiClient()
436 api.agentsList = listOf(soy.iko.opencode.data.model.Agent(name = "code"))
437 api.commandsList = listOf(Command(name = "compact"))
438 val container = makeContainer(api = api)
439 val vm = makeVm(container)
440 testScope.testScheduler.advanceUntilIdle()
441 assertEquals(1, vm.agents.value.size)
442 assertEquals("code", vm.agents.value[0].name)
443 assertEquals(1, vm.commands.value.size)
447 fun reloadModels_invalidatesCacheAndRefetches() = testScope.runTest {
448 val api = FakeOpencodeApiClient()
449 val container = makeContainer(api = api)
450 val vm = makeVm(container)
451 testScope.testScheduler.advanceUntilIdle()
452 assertFalse(api.providersCacheInvalidated)
454 api.providersResponse = ProvidersResponse(
456 Provider(id = "p1", name = "Provider 1", models = mapOf("m1" to ModelInfo(id = "m1", name = "Model 1"))),
460 testScope.testScheduler.advanceUntilIdle()
461 assertTrue(api.providersCacheInvalidated)
462 assertTrue(vm.models.value.isNotEmpty())
465 // --- reconnect() ---
468 fun reconnect_connectsToMostRecentProfile() = testScope.runTest {
469 val container = FakeAppContainer()
470 container.fakeProfileStore.setProfiles(
471 listOf(ServerProfile(id = "p1", label = "Server 1", baseUrl = "http://localhost")),
473 val vm = makeVm(container)
475 testScope.testScheduler.advanceUntilIdle()
476 assertEquals(1, container.connectCalls.size)
477 assertEquals("p1", container.connectCalls[0].id)
481 fun reconnect_withoutProfilesDoesNothing() = testScope.runTest {
482 val container = FakeAppContainer()
483 val vm = makeVm(container)
485 testScope.testScheduler.advanceUntilIdle()
486 assertEquals(0, container.connectCalls.size)
490 fun reconnect_skipsWhenAlreadyConnected() = testScope.runTest {
491 val container = makeContainer()
492 val vm = makeVm(container)
494 testScope.testScheduler.advanceUntilIdle()
495 assertEquals(0, container.connectCalls.size)
498 // --- Connection reset on server switch ---
501 fun connectionChange_resetsRunningAndErrorAndPermission() = testScope.runTest {
502 val events = FakeEventStreamClient()
503 val container = makeContainer(events = events)
504 val vm = makeVm(container)
505 assertTrue(vm.send("hello"))
506 testScope.testScheduler.advanceUntilIdle()
507 // Switch to a new connection
508 val api2 = FakeOpencodeApiClient()
509 val events2 = FakeEventStreamClient()
510 val repo2 = FakeSessionRepository(api2, events2)
511 val conn2 = FakeOpencodeConnection(api2, events2, repo2, ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
512 container.setActiveConnection(conn2)
513 testScope.testScheduler.advanceUntilIdle()
514 assertFalse(vm.running.value)
515 assertNull(vm.pendingPermission.value)
518 // --- rapid successive errors ---
521 fun rapidSuccessiveErrors_bothDeliveredAsEvents() = testScope.runTest {
522 val api = FakeOpencodeApiClient()
523 val events = FakeEventStreamClient()
524 val repo = FakeSessionRepository(api, events)
525 repo.sendPromptThrows = IOException("err")
526 val container = makeContainer(api = api, events = events, repo = repo)
527 val vm = makeVm(container)
529 val errors = mutableListOf<ChatError>()
530 val collectJob = launch { vm.errorEvents.toList(errors) }
532 // Two rapid sends that both fail — both should produce independent error
533 // events, not just the last one (the bug with StateFlow-keyed effects).
534 assertTrue(vm.send("first"))
535 testScheduler.advanceUntilIdle()
536 assertTrue(vm.send("second"))
537 testScheduler.advanceUntilIdle()
540 assertEquals(2, errors.size)
544 fun messagesFlowRetry_emitsErrorOnFailure() = testScope.runTest {
545 val api = FakeOpencodeApiClient()
546 val events = FakeEventStreamClient()
547 // Use a controllable flow so we can simulate a re-emission after an error.
548 val messagesFlow = kotlinx.coroutines.flow.MutableStateFlow(
549 listOf(MessageWithParts(info = UserMessage(id = "m1", sessionID = "session1")))
551 val repo = FakeSessionRepository(api, events)
552 repo.observeMessagesOverride = messagesFlow
553 val container = makeContainer(api = api, events = events, repo = repo)
554 val vm = makeVm(container)
555 val errors = mutableListOf<ChatError>()
556 val collectJob = launch { vm.errorEvents.toList(errors) }
557 // Subscribe to messages so the flow's retryWhen is active.
558 val collector = launch { vm.messages.collect { /* keep subscribed */ } }
559 testScope.testScheduler.advanceUntilIdle()
560 // Set an error via SSE
561 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
562 testScope.testScheduler.advanceUntilIdle()
565 assertTrue(errors.isNotEmpty())
568 // --- Connection switch clears session title ---
571 fun connectionChange_clearsSessionTitle() = testScope.runTest {
572 val events = FakeEventStreamClient()
573 val container = makeContainer(events = events)
574 val vm = makeVm(container)
575 // Set the title via SSE event
576 events.fakeEvents.tryEmit(
577 SessionUpdated(SessionUpdated.Props(info = Session(id = "session1", title = "Old Title"))),
579 testScope.testScheduler.advanceUntilIdle()
580 assertEquals("Old Title", vm.sessionTitle.value)
581 // Switch to a new connection
582 val api2 = FakeOpencodeApiClient()
583 val events2 = FakeEventStreamClient()
584 val repo2 = FakeSessionRepository(api2, events2)
585 val conn2 = FakeOpencodeConnection(api2, events2, repo2, ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
586 container.setActiveConnection(conn2)
587 testScope.testScheduler.advanceUntilIdle()
588 assertNull(vm.sessionTitle.value)
591 // --- editMessage ---
594 fun editMessage_prefillsDraftWithText() = testScope.runTest {
595 val container = makeContainer()
596 val vm = makeVm(container)
597 vm.editMessage("m1", "the original prompt")
598 testScheduler.advanceUntilIdle()
599 assertEquals("the original prompt", vm.draft.value)
602 // --- attachment persistence ---
604 private fun makeAttachment(id: String) = PendingAttachment(
609 part = soy.iko.opencode.data.model.FilePromptPart(
611 url = "data:text/plain;base64,QUJD",
612 filename = "$id.txt",
617 fun addAttachment_persistsToStore() = testScope.runTest {
618 val container = makeContainer()
619 val vm = makeVm(container)
620 vm.addAttachment(makeAttachment("a1"))
621 testScheduler.advanceUntilIdle()
622 assertEquals(1, container.fakeAttachmentDraftStore.load("session1").size)
626 fun attachments_restoredFromStoreOnInit() = testScope.runTest {
627 val container = makeContainer()
628 // Pre-populate the store as if a prior compose was interrupted by process death.
629 val att = makeAttachment("a1")
630 container.fakeAttachmentDraftStore.save(
632 listOf(soy.iko.opencode.data.repo.PersistedAttachment(att.id, att.name, att.mime, att.part.url, att.part.filename)),
634 val vm = makeVm(container)
635 testScheduler.advanceUntilIdle()
636 assertEquals(1, vm.attachments.value.size)
637 assertEquals("a1", vm.attachments.value[0].id)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list