git.iko.soy/opencode-android
on e4dceb6 help sign in
549 lines 21 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_returnsFalseWhenNotConnected() = testScope.runTest {
82 val container = FakeAppContainer()
83 val vm = makeVm(container)
84 assertFalse(vm.send("hello"))
85 }
87 @Test
88 fun send_returnsFalseForEmptyText() = testScope.runTest {
89 val container = makeContainer()
90 val vm = makeVm(container)
91 assertFalse(vm.send(""))
92 assertFalse(vm.send(" "))
93 }
95 @Test
96 fun send_returnsFalseWhenAlreadyRunning() = testScope.runTest {
97 val container = makeContainer()
98 val vm = makeVm(container)
99 assertTrue(vm.send("first"))
100 assertFalse(vm.send("second"))
101 }
103 @Test
104 fun send_clearsDraftImmediatelyAndSetsRunning() = testScope.runTest {
105 val container = makeContainer()
106 val vm = makeVm(container)
107 vm.updateDraft("hello world")
108 assertTrue(vm.send("hello world"))
109 assertEquals("", vm.draft.value)
110 assertTrue(vm.running.value)
111 }
113 @Test
114 fun send_successPersistsEmptyDraft() = testScope.runTest {
115 val container = makeContainer()
116 val vm = makeVm(container)
117 vm.updateDraft("hello")
118 assertTrue(vm.send("hello"))
119 testScope.testScheduler.advanceUntilIdle()
120 assertEquals("", container.fakeDraftStore.get("session1"))
121 assertNull(vm.failedDraft.value)
122 }
124 @Test
125 fun send_failureRestoresDraftAndClearsRunning() = testScope.runTest {
126 val api = FakeOpencodeApiClient()
127 val events = FakeEventStreamClient()
128 val repo = FakeSessionRepository(api, events)
129 repo.sendPromptThrows = IOException("network error")
130 val container = makeContainer(api = api, events = events, repo = repo)
131 val vm = makeVm(container)
132 vm.updateDraft("hello")
133 assertTrue(vm.send("hello"))
134 testScope.testScheduler.advanceUntilIdle()
135 assertEquals("hello", vm.draft.value)
136 assertEquals("hello", vm.failedDraft.value)
137 assertFalse(vm.running.value)
138 val errors = mutableListOf<String>()
139 val collectJob = launch { vm.errorEvents.toList(errors) }
140 assertTrue(vm.send("hello"))
141 testScheduler.advanceUntilIdle()
142 collectJob.cancel()
143 assertTrue(errors.isNotEmpty())
144 }
146 @Test
147 fun send_failureDoesNotRestoreDraftIfUserTypedNewText() = testScope.runTest {
148 val api = FakeOpencodeApiClient()
149 val events = FakeEventStreamClient()
150 val repo = FakeSessionRepository(api, events)
151 repo.sendPromptThrows = IOException("network error")
152 val container = makeContainer(api = api, events = events, repo = repo)
153 val vm = makeVm(container)
154 vm.updateDraft("original")
155 assertTrue(vm.send("original"))
156 vm.updateDraft("new text")
157 testScope.testScheduler.advanceUntilIdle()
158 assertEquals("new text", vm.draft.value)
159 }
161 // --- retryFailed() ---
163 @Test
164 fun retryFailed_noopWhenNoFailedDraft() = testScope.runTest {
165 val container = makeContainer()
166 val vm = makeVm(container)
167 vm.retryFailed()
168 testScope.testScheduler.advanceUntilIdle()
169 assertFalse(vm.running.value)
170 }
172 @Test
173 fun retryFailed_resendsFailedDraft() = testScope.runTest {
174 val api = FakeOpencodeApiClient()
175 val events = FakeEventStreamClient()
176 val repo = FakeSessionRepository(api, events)
177 repo.sendPromptThrows = IOException("network error")
178 val container = makeContainer(api = api, events = events, repo = repo)
179 val vm = makeVm(container)
180 vm.updateDraft("hello")
181 assertTrue(vm.send("hello"))
182 testScope.testScheduler.advanceUntilIdle()
183 assertEquals("hello", vm.failedDraft.value)
185 repo.sendPromptThrows = null
186 vm.retryFailed()
187 testScope.testScheduler.advanceUntilIdle()
188 assertNull(vm.failedDraft.value)
189 }
191 // --- runCommand() ---
193 @Test
194 fun runCommand_setsRunning() = testScope.runTest {
195 val container = makeContainer()
196 val vm = makeVm(container)
197 vm.runCommand(Command(name = "compact"))
198 assertTrue(vm.running.value)
199 }
201 @Test
202 fun runCommand_rejectedWhenAlreadyRunning() = testScope.runTest {
203 val container = makeContainer()
204 val vm = makeVm(container)
205 assertTrue(vm.send("hello"))
206 assertFalse(vm.running.value.not()) // running is true
207 vm.runCommand(Command(name = "compact"))
208 // Still running from the first send; runCommand should not override
209 assertTrue(vm.running.value)
210 }
212 // --- abort() ---
214 @Test
215 fun abort_clearsRunning() = testScope.runTest {
216 val container = makeContainer()
217 val vm = makeVm(container)
218 assertTrue(vm.send("hello"))
219 testScope.testScheduler.advanceUntilIdle()
220 vm.abort()
221 testScope.testScheduler.advanceUntilIdle()
222 assertFalse(vm.running.value)
223 }
225 // --- respondPermission() ---
227 @Test
228 fun respondPermission_clearsPendingOptimistically() = testScope.runTest {
229 val events = FakeEventStreamClient()
230 val container = makeContainer(events = events)
231 val vm = makeVm(container)
232 val perm = Permission(id = "p1", sessionID = "session1")
234 events.fakeEvents.tryEmit(PermissionUpdated(perm))
235 testScope.testScheduler.advanceUntilIdle()
236 assertEquals("p1", vm.pendingPermission.value?.id)
238 vm.respondPermission(perm, PermissionResponse.ONCE)
239 assertNull(vm.pendingPermission.value)
240 }
242 @Test
243 fun respondPermission_restoresPendingOnFailure() = testScope.runTest {
244 val api = FakeOpencodeApiClient()
245 api.respondPermissionThrows = IOException("network error")
246 val container = makeContainer(api = api)
247 val vm = makeVm(container)
248 val perm = Permission(id = "p1", sessionID = "session1")
250 val events = (container.activeConnection.value!!.events as FakeEventStreamClient)
251 events.fakeEvents.tryEmit(PermissionUpdated(perm))
252 testScope.testScheduler.advanceUntilIdle()
253 assertEquals("p1", vm.pendingPermission.value?.id)
255 val errors = mutableListOf<String>()
256 val collectJob = launch { vm.errorEvents.toList(errors) }
257 vm.respondPermission(perm, PermissionResponse.ONCE)
258 assertNull(vm.pendingPermission.value)
259 testScope.testScheduler.advanceUntilIdle()
260 collectJob.cancel()
261 assertEquals("p1", vm.pendingPermission.value?.id)
262 assertTrue(errors.isNotEmpty())
263 }
265 // --- SSE event handling ---
267 @Test
268 fun sseSessionIdle_resetsRunning() = testScope.runTest {
269 val events = FakeEventStreamClient()
270 val container = makeContainer(events = events)
271 val vm = makeVm(container)
272 assertTrue(vm.send("hello"))
273 testScope.testScheduler.advanceUntilIdle()
274 events.fakeEvents.tryEmit(SessionIdle(SessionIdle.Props(sessionID = "session1")))
275 testScope.testScheduler.advanceUntilIdle()
276 assertFalse(vm.running.value)
277 }
279 @Test
280 fun sseSessionError_resetsRunningAndEmitsError() = testScope.runTest {
281 val events = FakeEventStreamClient()
282 val container = makeContainer(events = events)
283 val vm = makeVm(container)
284 val errors = mutableListOf<String>()
285 val collectJob = launch { vm.errorEvents.toList(errors) }
286 assertTrue(vm.send("hello"))
287 testScope.testScheduler.advanceUntilIdle()
288 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
289 testScope.testScheduler.advanceUntilIdle()
290 collectJob.cancel()
291 assertFalse(vm.running.value)
292 assertTrue(errors.isNotEmpty())
293 }
295 @Test
296 fun sseSessionIdle_ignoresOtherSession() = testScope.runTest {
297 val events = FakeEventStreamClient()
298 val container = makeContainer(events = events)
299 val vm = makeVm(container)
300 assertTrue(vm.send("hello"))
301 testScope.testScheduler.advanceUntilIdle()
302 events.fakeEvents.tryEmit(SessionIdle(SessionIdle.Props(sessionID = "other")))
303 testScope.testScheduler.advanceUntilIdle()
304 assertTrue(vm.running.value)
305 }
307 @Test
308 fun sseSessionUpdated_updatesTitle() = testScope.runTest {
309 val events = FakeEventStreamClient()
310 val container = makeContainer(events = events)
311 val vm = makeVm(container)
312 events.fakeEvents.tryEmit(
313 SessionUpdated(SessionUpdated.Props(info = Session(id = "session1", title = "My Chat"))),
314 )
315 testScope.testScheduler.advanceUntilIdle()
316 assertEquals("My Chat", vm.sessionTitle.value)
317 }
319 @Test
320 fun sseSessionDeleted_setsDeletedFlag() = testScope.runTest {
321 val events = FakeEventStreamClient()
322 val container = makeContainer(events = events)
323 val vm = makeVm(container)
324 events.fakeEvents.tryEmit(
325 SessionDeleted(SessionDeleted.Props(sessionID = "session1")),
326 )
327 testScope.testScheduler.advanceUntilIdle()
328 assertTrue(vm.sessionDeleted.value)
329 }
331 // --- Connection state ---
333 @Test
334 fun sseDisconnected_resetsRunning() = testScope.runTest {
335 val events = FakeEventStreamClient()
336 val container = makeContainer(events = events)
337 val vm = makeVm(container)
338 assertTrue(vm.send("hello"))
339 testScope.testScheduler.advanceUntilIdle()
340 events.fakeState.value = EventStreamClient.ConnectionState.Disconnected
341 testScope.testScheduler.advanceUntilIdle()
342 assertFalse(vm.running.value)
343 }
345 @Test
346 fun sseFailed_resetsRunning() = testScope.runTest {
347 val events = FakeEventStreamClient()
348 val container = makeContainer(events = events)
349 val vm = makeVm(container)
350 assertTrue(vm.send("hello"))
351 testScope.testScheduler.advanceUntilIdle()
352 events.fakeState.value = EventStreamClient.ConnectionState.Failed
353 testScope.testScheduler.advanceUntilIdle()
354 assertFalse(vm.running.value)
355 }
357 // --- Draft persistence ---
359 @Test
360 fun updateDraft_doesNotPersistImmediately() = testScope.runTest {
361 val container = makeContainer()
362 val vm = makeVm(container)
363 vm.updateDraft("typing")
364 assertEquals("", container.fakeDraftStore.get("session1"))
365 assertEquals("typing", vm.draft.value)
366 }
368 @Test
369 fun updateDraft_persistsAfterDebounce() = testScope.runTest {
370 val container = makeContainer()
371 val vm = makeVm(container)
372 vm.updateDraft("typing")
373 testScope.testScheduler.advanceUntilIdle()
374 assertEquals("typing", container.fakeDraftStore.get("session1"))
375 }
377 @Test
378 fun onCleared_flushesPendingDraft() = testScope.runTest {
379 val container = makeContainer()
380 val vm = makeVm(container)
381 vm.updateDraft("unsent draft")
382 testScope.testScheduler.advanceUntilIdle()
383 // onCleared() is protected — invoke via reflection
384 val method = androidx.lifecycle.ViewModel::class.java.getDeclaredMethod("onCleared")
385 method.isAccessible = true
386 method.invoke(vm)
387 assertEquals("unsent draft", container.fakeDraftStore.lastFlushed?.second)
388 }
390 // --- Catalog loading ---
392 @Test
393 fun catalogs_loadFromApi() = testScope.runTest {
394 val api = FakeOpencodeApiClient()
395 api.agentsList = listOf(soy.iko.opencode.data.model.Agent(name = "code"))
396 api.commandsList = listOf(Command(name = "compact"))
397 val container = makeContainer(api = api)
398 val vm = makeVm(container)
399 testScope.testScheduler.advanceUntilIdle()
400 assertEquals(1, vm.agents.value.size)
401 assertEquals("code", vm.agents.value[0].name)
402 assertEquals(1, vm.commands.value.size)
403 }
405 @Test
406 fun reloadModels_invalidatesCacheAndRefetches() = testScope.runTest {
407 val api = FakeOpencodeApiClient()
408 val container = makeContainer(api = api)
409 val vm = makeVm(container)
410 testScope.testScheduler.advanceUntilIdle()
411 assertFalse(api.providersCacheInvalidated)
413 api.providersResponse = ProvidersResponse(
414 providers = listOf(
415 Provider(id = "p1", name = "Provider 1", models = mapOf("m1" to ModelInfo(id = "m1", name = "Model 1"))),
416 ),
417 )
418 vm.reloadModels()
419 testScope.testScheduler.advanceUntilIdle()
420 assertTrue(api.providersCacheInvalidated)
421 assertTrue(vm.models.value.isNotEmpty())
422 }
424 // --- reconnect() ---
426 @Test
427 fun reconnect_connectsToMostRecentProfile() = testScope.runTest {
428 val container = FakeAppContainer()
429 container.fakeProfileStore.setProfiles(
430 listOf(ServerProfile(id = "p1", label = "Server 1", baseUrl = "http://localhost")),
431 )
432 val vm = makeVm(container)
433 vm.reconnect()
434 testScope.testScheduler.advanceUntilIdle()
435 assertEquals(1, container.connectCalls.size)
436 assertEquals("p1", container.connectCalls[0].id)
437 }
439 @Test
440 fun reconnect_withoutProfilesDoesNothing() = testScope.runTest {
441 val container = FakeAppContainer()
442 val vm = makeVm(container)
443 vm.reconnect()
444 testScope.testScheduler.advanceUntilIdle()
445 assertEquals(0, container.connectCalls.size)
446 }
448 @Test
449 fun reconnect_skipsWhenAlreadyConnected() = testScope.runTest {
450 val container = makeContainer()
451 val vm = makeVm(container)
452 vm.reconnect()
453 testScope.testScheduler.advanceUntilIdle()
454 assertEquals(0, container.connectCalls.size)
455 }
457 // --- Connection reset on server switch ---
459 @Test
460 fun connectionChange_resetsRunningAndErrorAndPermission() = testScope.runTest {
461 val events = FakeEventStreamClient()
462 val container = makeContainer(events = events)
463 val vm = makeVm(container)
464 assertTrue(vm.send("hello"))
465 testScope.testScheduler.advanceUntilIdle()
466 // Switch to a new connection
467 val api2 = FakeOpencodeApiClient()
468 val events2 = FakeEventStreamClient()
469 val repo2 = FakeSessionRepository(api2, events2)
470 val conn2 = FakeOpencodeConnection(api2, events2, repo2, ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
471 container.setActiveConnection(conn2)
472 testScope.testScheduler.advanceUntilIdle()
473 assertFalse(vm.running.value)
474 assertNull(vm.pendingPermission.value)
475 }
477 // --- rapid successive errors ---
479 @Test
480 fun rapidSuccessiveErrors_bothDeliveredAsEvents() = testScope.runTest {
481 val api = FakeOpencodeApiClient()
482 val events = FakeEventStreamClient()
483 val repo = FakeSessionRepository(api, events)
484 repo.sendPromptThrows = IOException("err")
485 val container = makeContainer(api = api, events = events, repo = repo)
486 val vm = makeVm(container)
488 val errors = mutableListOf<String>()
489 val collectJob = launch { vm.errorEvents.toList(errors) }
491 // Two rapid sends that both fail — both should produce independent error
492 // events, not just the last one (the bug with StateFlow-keyed effects).
493 assertTrue(vm.send("first"))
494 testScheduler.advanceUntilIdle()
495 assertTrue(vm.send("second"))
496 testScheduler.advanceUntilIdle()
498 collectJob.cancel()
499 assertEquals(2, errors.size)
500 }
502 @Test
503 fun messagesFlowRetry_emitsErrorOnFailure() = testScope.runTest {
504 val api = FakeOpencodeApiClient()
505 val events = FakeEventStreamClient()
506 // Use a controllable flow so we can simulate a re-emission after an error.
507 val messagesFlow = kotlinx.coroutines.flow.MutableStateFlow(
508 listOf(MessageWithParts(info = UserMessage(id = "m1", sessionID = "session1")))
509 )
510 val repo = FakeSessionRepository(api, events)
511 repo.observeMessagesOverride = messagesFlow
512 val container = makeContainer(api = api, events = events, repo = repo)
513 val vm = makeVm(container)
514 val errors = mutableListOf<String>()
515 val collectJob = launch { vm.errorEvents.toList(errors) }
516 // Subscribe to messages so the flow's retryWhen is active.
517 val collector = launch { vm.messages.collect { /* keep subscribed */ } }
518 testScope.testScheduler.advanceUntilIdle()
519 // Set an error via SSE
520 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
521 testScope.testScheduler.advanceUntilIdle()
522 collectJob.cancel()
523 collector.cancel()
524 assertTrue(errors.isNotEmpty())
525 }
527 // --- Connection switch clears session title ---
529 @Test
530 fun connectionChange_clearsSessionTitle() = testScope.runTest {
531 val events = FakeEventStreamClient()
532 val container = makeContainer(events = events)
533 val vm = makeVm(container)
534 // Set the title via SSE event
535 events.fakeEvents.tryEmit(
536 SessionUpdated(SessionUpdated.Props(info = Session(id = "session1", title = "Old Title"))),
537 )
538 testScope.testScheduler.advanceUntilIdle()
539 assertEquals("Old Title", vm.sessionTitle.value)
540 // Switch to a new connection
541 val api2 = FakeOpencodeApiClient()
542 val events2 = FakeEventStreamClient()
543 val repo2 = FakeSessionRepository(api2, events2)
544 val conn2 = FakeOpencodeConnection(api2, events2, repo2, ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
545 container.setActiveConnection(conn2)
546 testScope.testScheduler.advanceUntilIdle()
547 assertNull(vm.sessionTitle.value)
548 }