git.iko.soy/opencode-android
on ca30f73 help sign in
601 lines 23 kB history raw
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
14import org.junit.After
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
21import org.junit.Test
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)
49 @Before
50 fun setUp() {
51 Dispatchers.setMain(testDispatcher)
52 }
54 @After
55 fun tearDown() {
56 Dispatchers.resetMain()
57 }
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"),
64 ): FakeAppContainer {
65 val container = FakeAppContainer()
66 val conn = FakeOpencodeConnection(api, events, repo, profile)
67 container.setActiveConnection(conn)
68 return container
69 }
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()
75 return vm
76 }
78 // --- send() ---
80 @Test
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"))
88 }
90 @Test
91 fun send_returnsFalseForEmptyText() = testScope.runTest {
92 val container = makeContainer()
93 val vm = makeVm(container)
94 assertFalse(vm.send(""))
95 assertFalse(vm.send(" "))
96 }
98 @Test
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"))
104 }
106 @Test
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)
114 }
116 @Test
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)
125 }
127 @Test
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()
145 collectJob.cancel()
146 assertTrue(errors.isNotEmpty())
147 }
149 @Test
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)
162 }
164 // --- retryFailed() ---
166 @Test
167 fun retryFailed_noopWhenNoFailedDraft() = testScope.runTest {
168 val container = makeContainer()
169 val vm = makeVm(container)
170 vm.retryFailed()
171 testScope.testScheduler.advanceUntilIdle()
172 assertFalse(vm.running.value)
173 }
175 @Test
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
189 vm.retryFailed()
190 testScope.testScheduler.advanceUntilIdle()
191 assertNull(vm.failedDraft.value)
192 }
194 // --- runCommand() ---
196 @Test
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)
202 }
204 @Test
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)
213 }
215 // --- abort() ---
217 @Test
218 fun abort_clearsRunning() = testScope.runTest {
219 val container = makeContainer()
220 val vm = makeVm(container)
221 assertTrue(vm.send("hello"))
222 testScope.testScheduler.advanceUntilIdle()
223 vm.abort()
224 testScope.testScheduler.advanceUntilIdle()
225 assertFalse(vm.running.value)
226 }
228 // --- respondPermission() ---
230 @Test
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)
243 }
245 @Test
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()
263 collectJob.cancel()
264 assertEquals("p1", vm.pendingPermission.value?.id)
265 assertTrue(errors.isNotEmpty())
266 }
268 // --- SSE event handling ---
270 @Test
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)
280 }
282 @Test
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()
293 collectJob.cancel()
294 assertFalse(vm.running.value)
295 assertTrue(errors.isNotEmpty())
296 }
298 @Test
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)
308 }
310 @Test
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"))),
317 )
318 testScope.testScheduler.advanceUntilIdle()
319 assertEquals("My Chat", vm.sessionTitle.value)
320 }
322 @Test
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")),
329 )
330 testScope.testScheduler.advanceUntilIdle()
331 assertTrue(vm.sessionDeleted.value)
332 }
334 // --- Connection state ---
336 @Test
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)
346 }
348 @Test
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)
358 }
360 // --- Draft persistence ---
362 @Test
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)
369 }
371 @Test
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"))
378 }
380 @Test
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
389 method.invoke(vm)
390 assertEquals("unsent draft", container.fakeDraftStore.lastFlushed?.second)
391 }
393 // --- Catalog loading ---
395 @Test
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)
406 }
408 @Test
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(
417 providers = listOf(
418 Provider(id = "p1", name = "Provider 1", models = mapOf("m1" to ModelInfo(id = "m1", name = "Model 1"))),
419 ),
420 )
421 vm.reloadModels()
422 testScope.testScheduler.advanceUntilIdle()
423 assertTrue(api.providersCacheInvalidated)
424 assertTrue(vm.models.value.isNotEmpty())
425 }
427 // --- reconnect() ---
429 @Test
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")),
434 )
435 val vm = makeVm(container)
436 vm.reconnect()
437 testScope.testScheduler.advanceUntilIdle()
438 assertEquals(1, container.connectCalls.size)
439 assertEquals("p1", container.connectCalls[0].id)
440 }
442 @Test
443 fun reconnect_withoutProfilesDoesNothing() = testScope.runTest {
444 val container = FakeAppContainer()
445 val vm = makeVm(container)
446 vm.reconnect()
447 testScope.testScheduler.advanceUntilIdle()
448 assertEquals(0, container.connectCalls.size)
449 }
451 @Test
452 fun reconnect_skipsWhenAlreadyConnected() = testScope.runTest {
453 val container = makeContainer()
454 val vm = makeVm(container)
455 vm.reconnect()
456 testScope.testScheduler.advanceUntilIdle()
457 assertEquals(0, container.connectCalls.size)
458 }
460 // --- Connection reset on server switch ---
462 @Test
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)
478 }
480 // --- rapid successive errors ---
482 @Test
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()
501 collectJob.cancel()
502 assertEquals(2, errors.size)
503 }
505 @Test
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")))
512 )
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()
525 collectJob.cancel()
526 collector.cancel()
527 assertTrue(errors.isNotEmpty())
528 }
530 // --- Connection switch clears session title ---
532 @Test
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"))),
540 )
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)
551 }
553 // --- editMessage ---
555 @Test
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)
562 }
564 // --- attachment persistence ---
566 private fun makeAttachment(id: String) = PendingAttachment(
567 id = id,
568 name = "$id.txt",
569 mime = "text/plain",
570 previewModel = null,
571 part = soy.iko.opencode.data.model.FilePromptPart(
572 mime = "text/plain",
573 url = "data:text/plain;base64,QUJD",
574 filename = "$id.txt",
575 ),
576 )
578 @Test
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)
585 }
587 @Test
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(
593 "session1",
594 listOf(soy.iko.opencode.data.repo.PersistedAttachment(att.id, att.name, att.mime, att.part.url, att.part.filename)),
595 )
596 val vm = makeVm(container)
597 testScheduler.advanceUntilIdle()
598 assertEquals(1, vm.attachments.value.size)
599 assertEquals("a1", vm.attachments.value[0].id)
600 }