git.iko.soy/opencode-android
on 598c306 help sign in
481 lines 18 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.test.StandardTestDispatcher
8import kotlinx.coroutines.test.TestScope
9import kotlinx.coroutines.test.resetMain
10import kotlinx.coroutines.test.runTest
11import kotlinx.coroutines.test.setMain
12import org.junit.After
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
19import org.junit.Test
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)
45 @Before
46 fun setUp() {
47 Dispatchers.setMain(testDispatcher)
48 }
50 @After
51 fun tearDown() {
52 Dispatchers.resetMain()
53 }
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"),
60 ): FakeAppContainer {
61 val container = FakeAppContainer()
62 val conn = FakeOpencodeConnection(api, events, repo, profile)
63 container.setActiveConnection(conn)
64 return container
65 }
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()
71 return vm
72 }
74 // --- send() ---
76 @Test
77 fun send_returnsFalseWhenNotConnected() = testScope.runTest {
78 val container = FakeAppContainer()
79 val vm = makeVm(container)
80 assertFalse(vm.send("hello"))
81 }
83 @Test
84 fun send_returnsFalseForEmptyText() = testScope.runTest {
85 val container = makeContainer()
86 val vm = makeVm(container)
87 assertFalse(vm.send(""))
88 assertFalse(vm.send(" "))
89 }
91 @Test
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"))
97 }
99 @Test
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)
107 }
109 @Test
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)
119 }
121 @Test
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)
136 }
138 @Test
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)
151 }
153 // --- retryFailed() ---
155 @Test
156 fun retryFailed_noopWhenNoFailedDraft() = testScope.runTest {
157 val container = makeContainer()
158 val vm = makeVm(container)
159 vm.retryFailed()
160 testScope.testScheduler.advanceUntilIdle()
161 assertFalse(vm.running.value)
162 }
164 @Test
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
178 vm.retryFailed()
179 testScope.testScheduler.advanceUntilIdle()
180 assertNull(vm.failedDraft.value)
181 }
183 // --- runCommand() ---
185 @Test
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)
191 }
193 @Test
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)
202 }
204 // --- abort() ---
206 @Test
207 fun abort_clearsRunning() = testScope.runTest {
208 val container = makeContainer()
209 val vm = makeVm(container)
210 assertTrue(vm.send("hello"))
211 testScope.testScheduler.advanceUntilIdle()
212 vm.abort()
213 testScope.testScheduler.advanceUntilIdle()
214 assertFalse(vm.running.value)
215 }
217 // --- respondPermission() ---
219 @Test
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)
232 }
234 @Test
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)
252 }
254 // --- SSE event handling ---
256 @Test
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)
266 }
268 @Test
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)
279 }
281 @Test
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)
291 }
293 @Test
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"))),
300 )
301 testScope.testScheduler.advanceUntilIdle()
302 assertEquals("My Chat", vm.sessionTitle.value)
303 }
305 @Test
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")),
312 )
313 testScope.testScheduler.advanceUntilIdle()
314 assertTrue(vm.sessionDeleted.value)
315 }
317 // --- Connection state ---
319 @Test
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)
329 }
331 @Test
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)
341 }
343 // --- Draft persistence ---
345 @Test
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)
352 }
354 @Test
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"))
361 }
363 @Test
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
372 method.invoke(vm)
373 assertEquals("unsent draft", container.fakeDraftStore.lastFlushed?.second)
374 }
376 // --- Catalog loading ---
378 @Test
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)
389 }
391 @Test
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(
400 providers = listOf(
401 Provider(id = "p1", name = "Provider 1", models = mapOf("m1" to ModelInfo(id = "m1", name = "Model 1"))),
402 ),
403 )
404 vm.reloadModels()
405 testScope.testScheduler.advanceUntilIdle()
406 assertTrue(api.cacheInvalidated)
407 assertTrue(vm.models.value.isNotEmpty())
408 }
410 // --- reconnect() ---
412 @Test
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")),
417 )
418 val vm = makeVm(container)
419 vm.reconnect()
420 testScope.testScheduler.advanceUntilIdle()
421 assertEquals(1, container.connectCalls.size)
422 assertEquals("p1", container.connectCalls[0].id)
423 }
425 @Test
426 fun reconnect_withoutProfilesDoesNothing() = testScope.runTest {
427 val container = FakeAppContainer()
428 val vm = makeVm(container)
429 vm.reconnect()
430 testScope.testScheduler.advanceUntilIdle()
431 assertEquals(0, container.connectCalls.size)
432 }
434 @Test
435 fun reconnect_skipsWhenAlreadyConnected() = testScope.runTest {
436 val container = makeContainer()
437 val vm = makeVm(container)
438 vm.reconnect()
439 testScope.testScheduler.advanceUntilIdle()
440 assertEquals(0, container.connectCalls.size)
441 }
443 // --- Connection reset on server switch ---
445 @Test
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)
462 }
464 // --- clearError ---
466 @Test
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)
478 vm.clearError()
479 assertNull(vm.error.value)
480 }