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.toList
9import kotlinx.coroutines.test.StandardTestDispatcher
10import kotlinx.coroutines.test.TestScope
11import kotlinx.coroutines.test.resetMain
12import kotlinx.coroutines.test.runTest
13import kotlinx.coroutines.test.setMain
15import org.junit.Assert.assertEquals
16import org.junit.Assert.assertFalse
17import org.junit.Assert.assertNotNull
18import org.junit.Assert.assertNull
19import org.junit.Assert.assertTrue
20import org.junit.Before
22import soy.iko.opencode.data.model.Command
23import soy.iko.opencode.data.model.MessageWithParts
24import soy.iko.opencode.data.model.ModelInfo
25import soy.iko.opencode.data.model.Permission
26import soy.iko.opencode.data.model.PermissionResponse
27import soy.iko.opencode.data.model.PermissionUpdated
28import soy.iko.opencode.data.model.Provider
29import soy.iko.opencode.data.model.ProvidersResponse
30import soy.iko.opencode.data.model.ServerProfile
31import soy.iko.opencode.data.model.Session
32import soy.iko.opencode.data.model.SessionDeleted
33import soy.iko.opencode.data.model.SessionError
34import soy.iko.opencode.data.model.SessionIdle
35import soy.iko.opencode.data.model.SessionUpdated
36import soy.iko.opencode.data.model.UserMessage
37import soy.iko.opencode.data.network.EventStreamClient
38import soy.iko.opencode.ui.testing.FakeAppContainer
39import soy.iko.opencode.ui.testing.FakeEventStreamClient
40import soy.iko.opencode.ui.testing.FakeOpencodeApiClient
41import soy.iko.opencode.ui.testing.FakeOpencodeConnection
42import soy.iko.opencode.ui.testing.FakeSessionRepository
43import java.io.IOException
45class ChatViewModelTest {
46 private val testDispatcher = StandardTestDispatcher()
47 private val testScope = TestScope(testDispatcher)
51 Dispatchers.setMain(testDispatcher)
56 Dispatchers.resetMain()
59 private fun makeContainer(
60 api: FakeOpencodeApiClient = FakeOpencodeApiClient(),
61 events: FakeEventStreamClient = FakeEventStreamClient(),
62 repo: FakeSessionRepository = FakeSessionRepository(api, events),
63 profile: ServerProfile = ServerProfile(id = "s1", label = "Test", baseUrl = "http://localhost"),
65 val container = FakeAppContainer()
66 val conn = FakeOpencodeConnection(api, events, repo, profile)
67 container.setActiveConnection(conn)
71 /** Creates a ViewModel and lets its init coroutines start before returning. */
72 private fun makeVm(container: FakeAppContainer, sessionId: String = "session1"): ChatViewModel {
73 val vm = ChatViewModel(container, sessionId)
74 testScope.testScheduler.advanceUntilIdle()
81 fun send_queuesToOutboxWhenNotConnected() = testScope.runTest {
82 // The offline-outbox feature changed send()'s contract: when there's no active
83 // connection the prompt is accepted for offline queueing (returns true) rather than
84 // being rejected. (This test previously asserted the old "returns false" behavior.)
85 val container = FakeAppContainer()
86 val vm = makeVm(container)
87 assertTrue(vm.send("hello"))
91 fun send_returnsFalseForEmptyText() = testScope.runTest {
92 val container = makeContainer()
93 val vm = makeVm(container)
94 assertFalse(vm.send(""))
95 assertFalse(vm.send(" "))
99 fun send_returnsFalseWhenAlreadyRunning() = testScope.runTest {
100 val container = makeContainer()
101 val vm = makeVm(container)
102 assertTrue(vm.send("first"))
103 assertFalse(vm.send("second"))
107 fun send_clearsDraftImmediatelyAndSetsRunning() = testScope.runTest {
108 val container = makeContainer()
109 val vm = makeVm(container)
110 vm.updateDraft("hello world")
111 assertTrue(vm.send("hello world"))
112 assertEquals("", vm.draft.value)
113 assertTrue(vm.running.value)
117 fun send_successPersistsEmptyDraft() = testScope.runTest {
118 val container = makeContainer()
119 val vm = makeVm(container)
120 vm.updateDraft("hello")
121 assertTrue(vm.send("hello"))
122 testScope.testScheduler.advanceUntilIdle()
123 assertEquals("", container.fakeDraftStore.get("session1"))
124 assertNull(vm.failedDraft.value)
128 fun send_failureRestoresDraftAndClearsRunning() = testScope.runTest {
129 val api = FakeOpencodeApiClient()
130 val events = FakeEventStreamClient()
131 val repo = FakeSessionRepository(api, events)
132 repo.sendPromptThrows = IOException("network error")
133 val container = makeContainer(api = api, events = events, repo = repo)
134 val vm = makeVm(container)
135 vm.updateDraft("hello")
136 assertTrue(vm.send("hello"))
137 testScope.testScheduler.advanceUntilIdle()
138 assertEquals("hello", vm.draft.value)
139 assertEquals("hello", vm.failedDraft.value)
140 assertFalse(vm.running.value)
141 val errors = mutableListOf<ChatError>()
142 val collectJob = launch { vm.errorEvents.toList(errors) }
143 assertTrue(vm.send("hello"))
144 testScheduler.advanceUntilIdle()
146 assertTrue(errors.isNotEmpty())
150 fun send_failureDoesNotRestoreDraftIfUserTypedNewText() = testScope.runTest {
151 val api = FakeOpencodeApiClient()
152 val events = FakeEventStreamClient()
153 val repo = FakeSessionRepository(api, events)
154 repo.sendPromptThrows = IOException("network error")
155 val container = makeContainer(api = api, events = events, repo = repo)
156 val vm = makeVm(container)
157 vm.updateDraft("original")
158 assertTrue(vm.send("original"))
159 vm.updateDraft("new text")
160 testScope.testScheduler.advanceUntilIdle()
161 assertEquals("new text", vm.draft.value)
164 // --- retryFailed() ---
167 fun retryFailed_noopWhenNoFailedDraft() = testScope.runTest {
168 val container = makeContainer()
169 val vm = makeVm(container)
171 testScope.testScheduler.advanceUntilIdle()
172 assertFalse(vm.running.value)
176 fun retryFailed_resendsFailedDraft() = testScope.runTest {
177 val api = FakeOpencodeApiClient()
178 val events = FakeEventStreamClient()
179 val repo = FakeSessionRepository(api, events)
180 repo.sendPromptThrows = IOException("network error")
181 val container = makeContainer(api = api, events = events, repo = repo)
182 val vm = makeVm(container)
183 vm.updateDraft("hello")
184 assertTrue(vm.send("hello"))
185 testScope.testScheduler.advanceUntilIdle()
186 assertEquals("hello", vm.failedDraft.value)
188 repo.sendPromptThrows = null
190 testScope.testScheduler.advanceUntilIdle()
191 assertNull(vm.failedDraft.value)
194 // --- runCommand() ---
197 fun runCommand_setsRunning() = testScope.runTest {
198 val container = makeContainer()
199 val vm = makeVm(container)
200 vm.runCommand(Command(name = "compact"))
201 assertTrue(vm.running.value)
205 fun runCommand_rejectedWhenAlreadyRunning() = testScope.runTest {
206 val container = makeContainer()
207 val vm = makeVm(container)
208 assertTrue(vm.send("hello"))
209 assertFalse(vm.running.value.not()) // running is true
210 vm.runCommand(Command(name = "compact"))
211 // Still running from the first send; runCommand should not override
212 assertTrue(vm.running.value)
218 fun abort_clearsRunning() = testScope.runTest {
219 val container = makeContainer()
220 val vm = makeVm(container)
221 assertTrue(vm.send("hello"))
222 testScope.testScheduler.advanceUntilIdle()
224 testScope.testScheduler.advanceUntilIdle()
225 assertFalse(vm.running.value)
228 // --- respondPermission() ---
231 fun respondPermission_clearsPendingOptimistically() = testScope.runTest {
232 val events = FakeEventStreamClient()
233 val container = makeContainer(events = events)
234 val vm = makeVm(container)
235 val perm = Permission(id = "p1", sessionID = "session1")
237 events.fakeEvents.tryEmit(PermissionUpdated(perm))
238 testScope.testScheduler.advanceUntilIdle()
239 assertEquals("p1", vm.pendingPermission.value?.id)
241 vm.respondPermission(perm, PermissionResponse.ONCE)
242 assertNull(vm.pendingPermission.value)
246 fun respondPermission_restoresPendingOnFailure() = testScope.runTest {
247 val api = FakeOpencodeApiClient()
248 api.respondPermissionThrows = IOException("network error")
249 val container = makeContainer(api = api)
250 val vm = makeVm(container)
251 val perm = Permission(id = "p1", sessionID = "session1")
253 val events = (container.activeConnection.value!!.events as FakeEventStreamClient)
254 events.fakeEvents.tryEmit(PermissionUpdated(perm))
255 testScope.testScheduler.advanceUntilIdle()
256 assertEquals("p1", vm.pendingPermission.value?.id)
258 val errors = mutableListOf<ChatError>()
259 val collectJob = launch { vm.errorEvents.toList(errors) }
260 vm.respondPermission(perm, PermissionResponse.ONCE)
261 assertNull(vm.pendingPermission.value)
262 testScope.testScheduler.advanceUntilIdle()
264 assertEquals("p1", vm.pendingPermission.value?.id)
265 assertTrue(errors.isNotEmpty())
268 // --- SSE event handling ---
271 fun sseSessionIdle_resetsRunning() = testScope.runTest {
272 val events = FakeEventStreamClient()
273 val container = makeContainer(events = events)
274 val vm = makeVm(container)
275 assertTrue(vm.send("hello"))
276 testScope.testScheduler.advanceUntilIdle()
277 events.fakeEvents.tryEmit(SessionIdle(SessionIdle.Props(sessionID = "session1")))
278 testScope.testScheduler.advanceUntilIdle()
279 assertFalse(vm.running.value)
283 fun sseSessionError_resetsRunningAndEmitsError() = testScope.runTest {
284 val events = FakeEventStreamClient()
285 val container = makeContainer(events = events)
286 val vm = makeVm(container)
287 val errors = mutableListOf<ChatError>()
288 val collectJob = launch { vm.errorEvents.toList(errors) }
289 assertTrue(vm.send("hello"))
290 testScope.testScheduler.advanceUntilIdle()
291 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
292 testScope.testScheduler.advanceUntilIdle()
294 assertFalse(vm.running.value)
295 assertTrue(errors.isNotEmpty())
299 fun sseSessionIdle_ignoresOtherSession() = testScope.runTest {
300 val events = FakeEventStreamClient()
301 val container = makeContainer(events = events)
302 val vm = makeVm(container)
303 assertTrue(vm.send("hello"))
304 testScope.testScheduler.advanceUntilIdle()
305 events.fakeEvents.tryEmit(SessionIdle(SessionIdle.Props(sessionID = "other")))
306 testScope.testScheduler.advanceUntilIdle()
307 assertTrue(vm.running.value)
311 fun sseSessionUpdated_updatesTitle() = testScope.runTest {
312 val events = FakeEventStreamClient()
313 val container = makeContainer(events = events)
314 val vm = makeVm(container)
315 events.fakeEvents.tryEmit(
316 SessionUpdated(SessionUpdated.Props(info = Session(id = "session1", title = "My Chat"))),
318 testScope.testScheduler.advanceUntilIdle()
319 assertEquals("My Chat", vm.sessionTitle.value)
323 fun sseSessionDeleted_setsDeletedFlag() = testScope.runTest {
324 val events = FakeEventStreamClient()
325 val container = makeContainer(events = events)
326 val vm = makeVm(container)
327 events.fakeEvents.tryEmit(
328 SessionDeleted(SessionDeleted.Props(sessionID = "session1")),
330 testScope.testScheduler.advanceUntilIdle()
331 assertTrue(vm.sessionDeleted.value)
334 // --- Connection state ---
337 fun sseDisconnected_resetsRunning() = testScope.runTest {
338 val events = FakeEventStreamClient()
339 val container = makeContainer(events = events)
340 val vm = makeVm(container)
341 assertTrue(vm.send("hello"))
342 testScope.testScheduler.advanceUntilIdle()
343 events.fakeState.value = EventStreamClient.ConnectionState.Disconnected
344 testScope.testScheduler.advanceUntilIdle()
345 assertFalse(vm.running.value)
349 fun sseFailed_resetsRunning() = testScope.runTest {
350 val events = FakeEventStreamClient()
351 val container = makeContainer(events = events)
352 val vm = makeVm(container)
353 assertTrue(vm.send("hello"))
354 testScope.testScheduler.advanceUntilIdle()
355 events.fakeState.value = EventStreamClient.ConnectionState.Failed
356 testScope.testScheduler.advanceUntilIdle()
357 assertFalse(vm.running.value)
360 // --- Draft persistence ---
363 fun updateDraft_doesNotPersistImmediately() = testScope.runTest {
364 val container = makeContainer()
365 val vm = makeVm(container)
366 vm.updateDraft("typing")
367 assertEquals("", container.fakeDraftStore.get("session1"))
368 assertEquals("typing", vm.draft.value)
372 fun updateDraft_persistsAfterDebounce() = testScope.runTest {
373 val container = makeContainer()
374 val vm = makeVm(container)
375 vm.updateDraft("typing")
376 testScope.testScheduler.advanceUntilIdle()
377 assertEquals("typing", container.fakeDraftStore.get("session1"))
381 fun onCleared_flushesPendingDraft() = testScope.runTest {
382 val container = makeContainer()
383 val vm = makeVm(container)
384 vm.updateDraft("unsent draft")
385 testScope.testScheduler.advanceUntilIdle()
386 // onCleared() is protected — invoke via reflection
387 val method = androidx.lifecycle.ViewModel::class.java.getDeclaredMethod("onCleared")
388 method.isAccessible = true
390 assertEquals("unsent draft", container.fakeDraftStore.lastFlushed?.second)
393 // --- Catalog loading ---
396 fun catalogs_loadFromApi() = testScope.runTest {
397 val api = FakeOpencodeApiClient()
398 api.agentsList = listOf(soy.iko.opencode.data.model.Agent(name = "code"))
399 api.commandsList = listOf(Command(name = "compact"))
400 val container = makeContainer(api = api)
401 val vm = makeVm(container)
402 testScope.testScheduler.advanceUntilIdle()
403 assertEquals(1, vm.agents.value.size)
404 assertEquals("code", vm.agents.value[0].name)
405 assertEquals(1, vm.commands.value.size)
409 fun reloadModels_invalidatesCacheAndRefetches() = testScope.runTest {
410 val api = FakeOpencodeApiClient()
411 val container = makeContainer(api = api)
412 val vm = makeVm(container)
413 testScope.testScheduler.advanceUntilIdle()
414 assertFalse(api.providersCacheInvalidated)
416 api.providersResponse = ProvidersResponse(
418 Provider(id = "p1", name = "Provider 1", models = mapOf("m1" to ModelInfo(id = "m1", name = "Model 1"))),
422 testScope.testScheduler.advanceUntilIdle()
423 assertTrue(api.providersCacheInvalidated)
424 assertTrue(vm.models.value.isNotEmpty())
427 // --- reconnect() ---
430 fun reconnect_connectsToMostRecentProfile() = testScope.runTest {
431 val container = FakeAppContainer()
432 container.fakeProfileStore.setProfiles(
433 listOf(ServerProfile(id = "p1", label = "Server 1", baseUrl = "http://localhost")),
435 val vm = makeVm(container)
437 testScope.testScheduler.advanceUntilIdle()
438 assertEquals(1, container.connectCalls.size)
439 assertEquals("p1", container.connectCalls[0].id)
443 fun reconnect_withoutProfilesDoesNothing() = testScope.runTest {
444 val container = FakeAppContainer()
445 val vm = makeVm(container)
447 testScope.testScheduler.advanceUntilIdle()
448 assertEquals(0, container.connectCalls.size)
452 fun reconnect_skipsWhenAlreadyConnected() = testScope.runTest {
453 val container = makeContainer()
454 val vm = makeVm(container)
456 testScope.testScheduler.advanceUntilIdle()
457 assertEquals(0, container.connectCalls.size)
460 // --- Connection reset on server switch ---
463 fun connectionChange_resetsRunningAndErrorAndPermission() = testScope.runTest {
464 val events = FakeEventStreamClient()
465 val container = makeContainer(events = events)
466 val vm = makeVm(container)
467 assertTrue(vm.send("hello"))
468 testScope.testScheduler.advanceUntilIdle()
469 // Switch to a new connection
470 val api2 = FakeOpencodeApiClient()
471 val events2 = FakeEventStreamClient()
472 val repo2 = FakeSessionRepository(api2, events2)
473 val conn2 = FakeOpencodeConnection(api2, events2, repo2, ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
474 container.setActiveConnection(conn2)
475 testScope.testScheduler.advanceUntilIdle()
476 assertFalse(vm.running.value)
477 assertNull(vm.pendingPermission.value)
480 // --- rapid successive errors ---
483 fun rapidSuccessiveErrors_bothDeliveredAsEvents() = testScope.runTest {
484 val api = FakeOpencodeApiClient()
485 val events = FakeEventStreamClient()
486 val repo = FakeSessionRepository(api, events)
487 repo.sendPromptThrows = IOException("err")
488 val container = makeContainer(api = api, events = events, repo = repo)
489 val vm = makeVm(container)
491 val errors = mutableListOf<ChatError>()
492 val collectJob = launch { vm.errorEvents.toList(errors) }
494 // Two rapid sends that both fail — both should produce independent error
495 // events, not just the last one (the bug with StateFlow-keyed effects).
496 assertTrue(vm.send("first"))
497 testScheduler.advanceUntilIdle()
498 assertTrue(vm.send("second"))
499 testScheduler.advanceUntilIdle()
502 assertEquals(2, errors.size)
506 fun messagesFlowRetry_emitsErrorOnFailure() = testScope.runTest {
507 val api = FakeOpencodeApiClient()
508 val events = FakeEventStreamClient()
509 // Use a controllable flow so we can simulate a re-emission after an error.
510 val messagesFlow = kotlinx.coroutines.flow.MutableStateFlow(
511 listOf(MessageWithParts(info = UserMessage(id = "m1", sessionID = "session1")))
513 val repo = FakeSessionRepository(api, events)
514 repo.observeMessagesOverride = messagesFlow
515 val container = makeContainer(api = api, events = events, repo = repo)
516 val vm = makeVm(container)
517 val errors = mutableListOf<ChatError>()
518 val collectJob = launch { vm.errorEvents.toList(errors) }
519 // Subscribe to messages so the flow's retryWhen is active.
520 val collector = launch { vm.messages.collect { /* keep subscribed */ } }
521 testScope.testScheduler.advanceUntilIdle()
522 // Set an error via SSE
523 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
524 testScope.testScheduler.advanceUntilIdle()
527 assertTrue(errors.isNotEmpty())
530 // --- Connection switch clears session title ---
533 fun connectionChange_clearsSessionTitle() = testScope.runTest {
534 val events = FakeEventStreamClient()
535 val container = makeContainer(events = events)
536 val vm = makeVm(container)
537 // Set the title via SSE event
538 events.fakeEvents.tryEmit(
539 SessionUpdated(SessionUpdated.Props(info = Session(id = "session1", title = "Old Title"))),
541 testScope.testScheduler.advanceUntilIdle()
542 assertEquals("Old Title", vm.sessionTitle.value)
543 // Switch to a new connection
544 val api2 = FakeOpencodeApiClient()
545 val events2 = FakeEventStreamClient()
546 val repo2 = FakeSessionRepository(api2, events2)
547 val conn2 = FakeOpencodeConnection(api2, events2, repo2, ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
548 container.setActiveConnection(conn2)
549 testScope.testScheduler.advanceUntilIdle()
550 assertNull(vm.sessionTitle.value)
553 // --- editMessage ---
556 fun editMessage_prefillsDraftWithText() = testScope.runTest {
557 val container = makeContainer()
558 val vm = makeVm(container)
559 vm.editMessage("m1", "the original prompt")
560 testScheduler.advanceUntilIdle()
561 assertEquals("the original prompt", vm.draft.value)
564 // --- attachment persistence ---
566 private fun makeAttachment(id: String) = PendingAttachment(
571 part = soy.iko.opencode.data.model.FilePromptPart(
573 url = "data:text/plain;base64,QUJD",
574 filename = "$id.txt",
579 fun addAttachment_persistsToStore() = testScope.runTest {
580 val container = makeContainer()
581 val vm = makeVm(container)
582 vm.addAttachment(makeAttachment("a1"))
583 testScheduler.advanceUntilIdle()
584 assertEquals(1, container.fakeAttachmentDraftStore.load("session1").size)
588 fun attachments_restoredFromStoreOnInit() = testScope.runTest {
589 val container = makeContainer()
590 // Pre-populate the store as if a prior compose was interrupted by process death.
591 val att = makeAttachment("a1")
592 container.fakeAttachmentDraftStore.save(
594 listOf(soy.iko.opencode.data.repo.PersistedAttachment(att.id, att.name, att.mime, att.part.url, att.part.filename)),
596 val vm = makeVm(container)
597 testScheduler.advanceUntilIdle()
598 assertEquals(1, vm.attachments.value.size)
599 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