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.test.StandardTestDispatcher
8import kotlinx.coroutines.test.TestScope
9import kotlinx.coroutines.test.resetMain
10import kotlinx.coroutines.test.runTest
11import kotlinx.coroutines.test.setMain
13import org.junit.Assert.assertEquals
14import org.junit.Assert.assertFalse
15import org.junit.Assert.assertNotNull
16import org.junit.Assert.assertNull
17import org.junit.Assert.assertTrue
18import org.junit.Before
20import soy.iko.opencode.data.model.Command
21import soy.iko.opencode.data.model.ModelInfo
22import soy.iko.opencode.data.model.Permission
23import soy.iko.opencode.data.model.PermissionResponse
24import soy.iko.opencode.data.model.PermissionUpdated
25import soy.iko.opencode.data.model.Provider
26import soy.iko.opencode.data.model.ProvidersResponse
27import soy.iko.opencode.data.model.ServerProfile
28import soy.iko.opencode.data.model.Session
29import soy.iko.opencode.data.model.SessionDeleted
30import soy.iko.opencode.data.model.SessionError
31import soy.iko.opencode.data.model.SessionIdle
32import soy.iko.opencode.data.model.SessionUpdated
33import soy.iko.opencode.data.network.EventStreamClient
34import soy.iko.opencode.ui.testing.FakeAppContainer
35import soy.iko.opencode.ui.testing.FakeEventStreamClient
36import soy.iko.opencode.ui.testing.FakeOpencodeApiClient
37import soy.iko.opencode.ui.testing.FakeOpencodeConnection
38import soy.iko.opencode.ui.testing.FakeSessionRepository
39import java.io.IOException
41class ChatViewModelTest {
42 private val testDispatcher = StandardTestDispatcher()
43 private val testScope = TestScope(testDispatcher)
47 Dispatchers.setMain(testDispatcher)
52 Dispatchers.resetMain()
55 private fun makeContainer(
56 api: FakeOpencodeApiClient = FakeOpencodeApiClient(),
57 events: FakeEventStreamClient = FakeEventStreamClient(),
58 repo: FakeSessionRepository = FakeSessionRepository(api, events),
59 profile: ServerProfile = ServerProfile(id = "s1", label = "Test", baseUrl = "http://localhost"),
61 val container = FakeAppContainer()
62 val conn = FakeOpencodeConnection(api, events, repo, profile)
63 container.setActiveConnection(conn)
67 /** Creates a ViewModel and lets its init coroutines start before returning. */
68 private fun makeVm(container: FakeAppContainer, sessionId: String = "session1"): ChatViewModel {
69 val vm = ChatViewModel(container, sessionId)
70 testScope.testScheduler.advanceUntilIdle()
77 fun send_returnsFalseWhenNotConnected() = testScope.runTest {
78 val container = FakeAppContainer()
79 val vm = makeVm(container)
80 assertFalse(vm.send("hello"))
84 fun send_returnsFalseForEmptyText() = testScope.runTest {
85 val container = makeContainer()
86 val vm = makeVm(container)
87 assertFalse(vm.send(""))
88 assertFalse(vm.send(" "))
92 fun send_returnsFalseWhenAlreadyRunning() = testScope.runTest {
93 val container = makeContainer()
94 val vm = makeVm(container)
95 assertTrue(vm.send("first"))
96 assertFalse(vm.send("second"))
100 fun send_clearsDraftImmediatelyAndSetsRunning() = testScope.runTest {
101 val container = makeContainer()
102 val vm = makeVm(container)
103 vm.updateDraft("hello world")
104 assertTrue(vm.send("hello world"))
105 assertEquals("", vm.draft.value)
106 assertTrue(vm.running.value)
110 fun send_successPersistsEmptyDraft() = testScope.runTest {
111 val container = makeContainer()
112 val vm = makeVm(container)
113 vm.updateDraft("hello")
114 assertTrue(vm.send("hello"))
115 testScope.testScheduler.advanceUntilIdle()
116 assertEquals("", container.fakeDraftStore.get("session1"))
117 assertNull(vm.failedDraft.value)
118 assertNull(vm.error.value)
122 fun send_failureRestoresDraftAndClearsRunning() = testScope.runTest {
123 val api = FakeOpencodeApiClient()
124 val events = FakeEventStreamClient()
125 val repo = FakeSessionRepository(api, events)
126 repo.sendPromptThrows = IOException("network error")
127 val container = makeContainer(api = api, events = events, repo = repo)
128 val vm = makeVm(container)
129 vm.updateDraft("hello")
130 assertTrue(vm.send("hello"))
131 testScope.testScheduler.advanceUntilIdle()
132 assertEquals("hello", vm.draft.value)
133 assertEquals("hello", vm.failedDraft.value)
134 assertFalse(vm.running.value)
135 assertNotNull(vm.error.value)
139 fun send_failureDoesNotRestoreDraftIfUserTypedNewText() = testScope.runTest {
140 val api = FakeOpencodeApiClient()
141 val events = FakeEventStreamClient()
142 val repo = FakeSessionRepository(api, events)
143 repo.sendPromptThrows = IOException("network error")
144 val container = makeContainer(api = api, events = events, repo = repo)
145 val vm = makeVm(container)
146 vm.updateDraft("original")
147 assertTrue(vm.send("original"))
148 vm.updateDraft("new text")
149 testScope.testScheduler.advanceUntilIdle()
150 assertEquals("new text", vm.draft.value)
153 // --- retryFailed() ---
156 fun retryFailed_noopWhenNoFailedDraft() = testScope.runTest {
157 val container = makeContainer()
158 val vm = makeVm(container)
160 testScope.testScheduler.advanceUntilIdle()
161 assertFalse(vm.running.value)
165 fun retryFailed_resendsFailedDraft() = testScope.runTest {
166 val api = FakeOpencodeApiClient()
167 val events = FakeEventStreamClient()
168 val repo = FakeSessionRepository(api, events)
169 repo.sendPromptThrows = IOException("network error")
170 val container = makeContainer(api = api, events = events, repo = repo)
171 val vm = makeVm(container)
172 vm.updateDraft("hello")
173 assertTrue(vm.send("hello"))
174 testScope.testScheduler.advanceUntilIdle()
175 assertEquals("hello", vm.failedDraft.value)
177 repo.sendPromptThrows = null
179 testScope.testScheduler.advanceUntilIdle()
180 assertNull(vm.failedDraft.value)
183 // --- runCommand() ---
186 fun runCommand_setsRunning() = testScope.runTest {
187 val container = makeContainer()
188 val vm = makeVm(container)
189 vm.runCommand(Command(name = "compact"))
190 assertTrue(vm.running.value)
194 fun runCommand_rejectedWhenAlreadyRunning() = testScope.runTest {
195 val container = makeContainer()
196 val vm = makeVm(container)
197 assertTrue(vm.send("hello"))
198 assertFalse(vm.running.value.not()) // running is true
199 vm.runCommand(Command(name = "compact"))
200 // Still running from the first send; runCommand should not override
201 assertTrue(vm.running.value)
207 fun abort_clearsRunning() = testScope.runTest {
208 val container = makeContainer()
209 val vm = makeVm(container)
210 assertTrue(vm.send("hello"))
211 testScope.testScheduler.advanceUntilIdle()
213 testScope.testScheduler.advanceUntilIdle()
214 assertFalse(vm.running.value)
217 // --- respondPermission() ---
220 fun respondPermission_clearsPendingOptimistically() = testScope.runTest {
221 val events = FakeEventStreamClient()
222 val container = makeContainer(events = events)
223 val vm = makeVm(container)
224 val perm = Permission(id = "p1", sessionID = "session1")
226 events.fakeEvents.tryEmit(PermissionUpdated(perm))
227 testScope.testScheduler.advanceUntilIdle()
228 assertEquals("p1", vm.pendingPermission.value?.id)
230 vm.respondPermission(perm, PermissionResponse.ONCE)
231 assertNull(vm.pendingPermission.value)
235 fun respondPermission_restoresPendingOnFailure() = testScope.runTest {
236 val api = FakeOpencodeApiClient()
237 api.respondPermissionThrows = IOException("network error")
238 val container = makeContainer(api = api)
239 val vm = makeVm(container)
240 val perm = Permission(id = "p1", sessionID = "session1")
242 val events = (container.activeConnection.value!!.events as FakeEventStreamClient)
243 events.fakeEvents.tryEmit(PermissionUpdated(perm))
244 testScope.testScheduler.advanceUntilIdle()
245 assertEquals("p1", vm.pendingPermission.value?.id)
247 vm.respondPermission(perm, PermissionResponse.ONCE)
248 assertNull(vm.pendingPermission.value)
249 testScope.testScheduler.advanceUntilIdle()
250 assertEquals("p1", vm.pendingPermission.value?.id)
251 assertNotNull(vm.error.value)
254 // --- SSE event handling ---
257 fun sseSessionIdle_resetsRunning() = testScope.runTest {
258 val events = FakeEventStreamClient()
259 val container = makeContainer(events = events)
260 val vm = makeVm(container)
261 assertTrue(vm.send("hello"))
262 testScope.testScheduler.advanceUntilIdle()
263 events.fakeEvents.tryEmit(SessionIdle(SessionIdle.Props(sessionID = "session1")))
264 testScope.testScheduler.advanceUntilIdle()
265 assertFalse(vm.running.value)
269 fun sseSessionError_resetsRunningAndSetsError() = testScope.runTest {
270 val events = FakeEventStreamClient()
271 val container = makeContainer(events = events)
272 val vm = makeVm(container)
273 assertTrue(vm.send("hello"))
274 testScope.testScheduler.advanceUntilIdle()
275 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
276 testScope.testScheduler.advanceUntilIdle()
277 assertFalse(vm.running.value)
278 assertNotNull(vm.error.value)
282 fun sseSessionIdle_ignoresOtherSession() = testScope.runTest {
283 val events = FakeEventStreamClient()
284 val container = makeContainer(events = events)
285 val vm = makeVm(container)
286 assertTrue(vm.send("hello"))
287 testScope.testScheduler.advanceUntilIdle()
288 events.fakeEvents.tryEmit(SessionIdle(SessionIdle.Props(sessionID = "other")))
289 testScope.testScheduler.advanceUntilIdle()
290 assertTrue(vm.running.value)
294 fun sseSessionUpdated_updatesTitle() = testScope.runTest {
295 val events = FakeEventStreamClient()
296 val container = makeContainer(events = events)
297 val vm = makeVm(container)
298 events.fakeEvents.tryEmit(
299 SessionUpdated(SessionUpdated.Props(info = Session(id = "session1", title = "My Chat"))),
301 testScope.testScheduler.advanceUntilIdle()
302 assertEquals("My Chat", vm.sessionTitle.value)
306 fun sseSessionDeleted_setsDeletedFlag() = testScope.runTest {
307 val events = FakeEventStreamClient()
308 val container = makeContainer(events = events)
309 val vm = makeVm(container)
310 events.fakeEvents.tryEmit(
311 SessionDeleted(SessionDeleted.Props(sessionID = "session1")),
313 testScope.testScheduler.advanceUntilIdle()
314 assertTrue(vm.sessionDeleted.value)
317 // --- Connection state ---
320 fun sseDisconnected_resetsRunning() = testScope.runTest {
321 val events = FakeEventStreamClient()
322 val container = makeContainer(events = events)
323 val vm = makeVm(container)
324 assertTrue(vm.send("hello"))
325 testScope.testScheduler.advanceUntilIdle()
326 events.fakeState.value = EventStreamClient.ConnectionState.Disconnected
327 testScope.testScheduler.advanceUntilIdle()
328 assertFalse(vm.running.value)
332 fun sseFailed_resetsRunning() = testScope.runTest {
333 val events = FakeEventStreamClient()
334 val container = makeContainer(events = events)
335 val vm = makeVm(container)
336 assertTrue(vm.send("hello"))
337 testScope.testScheduler.advanceUntilIdle()
338 events.fakeState.value = EventStreamClient.ConnectionState.Failed
339 testScope.testScheduler.advanceUntilIdle()
340 assertFalse(vm.running.value)
343 // --- Draft persistence ---
346 fun updateDraft_doesNotPersistImmediately() = testScope.runTest {
347 val container = makeContainer()
348 val vm = makeVm(container)
349 vm.updateDraft("typing")
350 assertEquals("", container.fakeDraftStore.get("session1"))
351 assertEquals("typing", vm.draft.value)
355 fun updateDraft_persistsAfterDebounce() = testScope.runTest {
356 val container = makeContainer()
357 val vm = makeVm(container)
358 vm.updateDraft("typing")
359 testScope.testScheduler.advanceUntilIdle()
360 assertEquals("typing", container.fakeDraftStore.get("session1"))
364 fun onCleared_flushesPendingDraft() = testScope.runTest {
365 val container = makeContainer()
366 val vm = makeVm(container)
367 vm.updateDraft("unsent draft")
368 testScope.testScheduler.advanceUntilIdle()
369 // onCleared() is protected — invoke via reflection
370 val method = androidx.lifecycle.ViewModel::class.java.getDeclaredMethod("onCleared")
371 method.isAccessible = true
373 assertEquals("unsent draft", container.fakeDraftStore.lastFlushed?.second)
376 // --- Catalog loading ---
379 fun catalogs_loadFromApi() = testScope.runTest {
380 val api = FakeOpencodeApiClient()
381 api.agentsList = listOf(soy.iko.opencode.data.model.Agent(name = "code"))
382 api.commandsList = listOf(Command(name = "compact"))
383 val container = makeContainer(api = api)
384 val vm = makeVm(container)
385 testScope.testScheduler.advanceUntilIdle()
386 assertEquals(1, vm.agents.value.size)
387 assertEquals("code", vm.agents.value[0].name)
388 assertEquals(1, vm.commands.value.size)
392 fun reloadModels_invalidatesCacheAndRefetches() = testScope.runTest {
393 val api = FakeOpencodeApiClient()
394 val container = makeContainer(api = api)
395 val vm = makeVm(container)
396 testScope.testScheduler.advanceUntilIdle()
397 assertFalse(api.cacheInvalidated)
399 api.providersResponse = ProvidersResponse(
401 Provider(id = "p1", name = "Provider 1", models = mapOf("m1" to ModelInfo(id = "m1", name = "Model 1"))),
405 testScope.testScheduler.advanceUntilIdle()
406 assertTrue(api.cacheInvalidated)
407 assertTrue(vm.models.value.isNotEmpty())
410 // --- reconnect() ---
413 fun reconnect_connectsToMostRecentProfile() = testScope.runTest {
414 val container = FakeAppContainer()
415 container.fakeProfileStore.setProfiles(
416 listOf(ServerProfile(id = "p1", label = "Server 1", baseUrl = "http://localhost")),
418 val vm = makeVm(container)
420 testScope.testScheduler.advanceUntilIdle()
421 assertEquals(1, container.connectCalls.size)
422 assertEquals("p1", container.connectCalls[0].id)
426 fun reconnect_withoutProfilesDoesNothing() = testScope.runTest {
427 val container = FakeAppContainer()
428 val vm = makeVm(container)
430 testScope.testScheduler.advanceUntilIdle()
431 assertEquals(0, container.connectCalls.size)
435 fun reconnect_skipsWhenAlreadyConnected() = testScope.runTest {
436 val container = makeContainer()
437 val vm = makeVm(container)
439 testScope.testScheduler.advanceUntilIdle()
440 assertEquals(0, container.connectCalls.size)
443 // --- Connection reset on server switch ---
446 fun connectionChange_resetsRunningAndErrorAndPermission() = testScope.runTest {
447 val events = FakeEventStreamClient()
448 val container = makeContainer(events = events)
449 val vm = makeVm(container)
450 assertTrue(vm.send("hello"))
451 testScope.testScheduler.advanceUntilIdle()
452 // Switch to a new connection
453 val api2 = FakeOpencodeApiClient()
454 val events2 = FakeEventStreamClient()
455 val repo2 = FakeSessionRepository(api2, events2)
456 val conn2 = FakeOpencodeConnection(api2, events2, repo2, ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
457 container.setActiveConnection(conn2)
458 testScope.testScheduler.advanceUntilIdle()
459 assertFalse(vm.running.value)
460 assertNull(vm.error.value)
461 assertNull(vm.pendingPermission.value)
464 // --- clearError ---
467 fun clearError_clearsError() = testScope.runTest {
468 val api = FakeOpencodeApiClient()
469 val events = FakeEventStreamClient()
470 val repo = FakeSessionRepository(api, events)
471 repo.sendPromptThrows = IOException("err")
472 val container = makeContainer(api = api, events = events, repo = repo)
473 val vm = makeVm(container)
474 vm.updateDraft("hello")
475 assertTrue(vm.send("hello"))
476 testScope.testScheduler.advanceUntilIdle()
477 assertNotNull(vm.error.value)
479 assertNull(vm.error.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