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_returnsFalseWhenNotConnected() = testScope.runTest {
82 val container = FakeAppContainer()
83 val vm = makeVm(container)
84 assertFalse(vm.send("hello"))
88 fun send_returnsFalseForEmptyText() = testScope.runTest {
89 val container = makeContainer()
90 val vm = makeVm(container)
91 assertFalse(vm.send(""))
92 assertFalse(vm.send(" "))
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"))
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)
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)
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()
143 assertTrue(errors.isNotEmpty())
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)
161 // --- retryFailed() ---
164 fun retryFailed_noopWhenNoFailedDraft() = testScope.runTest {
165 val container = makeContainer()
166 val vm = makeVm(container)
168 testScope.testScheduler.advanceUntilIdle()
169 assertFalse(vm.running.value)
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
187 testScope.testScheduler.advanceUntilIdle()
188 assertNull(vm.failedDraft.value)
191 // --- runCommand() ---
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)
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)
215 fun abort_clearsRunning() = testScope.runTest {
216 val container = makeContainer()
217 val vm = makeVm(container)
218 assertTrue(vm.send("hello"))
219 testScope.testScheduler.advanceUntilIdle()
221 testScope.testScheduler.advanceUntilIdle()
222 assertFalse(vm.running.value)
225 // --- respondPermission() ---
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)
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()
261 assertEquals("p1", vm.pendingPermission.value?.id)
262 assertTrue(errors.isNotEmpty())
265 // --- SSE event handling ---
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)
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()
291 assertFalse(vm.running.value)
292 assertTrue(errors.isNotEmpty())
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)
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"))),
315 testScope.testScheduler.advanceUntilIdle()
316 assertEquals("My Chat", vm.sessionTitle.value)
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")),
327 testScope.testScheduler.advanceUntilIdle()
328 assertTrue(vm.sessionDeleted.value)
331 // --- Connection state ---
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)
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)
357 // --- Draft persistence ---
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)
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"))
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
387 assertEquals("unsent draft", container.fakeDraftStore.lastFlushed?.second)
390 // --- Catalog loading ---
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)
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(
415 Provider(id = "p1", name = "Provider 1", models = mapOf("m1" to ModelInfo(id = "m1", name = "Model 1"))),
419 testScope.testScheduler.advanceUntilIdle()
420 assertTrue(api.providersCacheInvalidated)
421 assertTrue(vm.models.value.isNotEmpty())
424 // --- reconnect() ---
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")),
432 val vm = makeVm(container)
434 testScope.testScheduler.advanceUntilIdle()
435 assertEquals(1, container.connectCalls.size)
436 assertEquals("p1", container.connectCalls[0].id)
440 fun reconnect_withoutProfilesDoesNothing() = testScope.runTest {
441 val container = FakeAppContainer()
442 val vm = makeVm(container)
444 testScope.testScheduler.advanceUntilIdle()
445 assertEquals(0, container.connectCalls.size)
449 fun reconnect_skipsWhenAlreadyConnected() = testScope.runTest {
450 val container = makeContainer()
451 val vm = makeVm(container)
453 testScope.testScheduler.advanceUntilIdle()
454 assertEquals(0, container.connectCalls.size)
457 // --- Connection reset on server switch ---
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)
477 // --- rapid successive errors ---
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()
499 assertEquals(2, errors.size)
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")))
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()
524 assertTrue(errors.isNotEmpty())
527 // --- Connection switch clears session title ---
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"))),
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)
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