@@ -0,0 +1,481 @@
1+@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
3+package soy.iko.opencode.ui.chat
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.ExperimentalCoroutinesApi
7+import kotlinx.coroutines.test.StandardTestDispatcher
8+import kotlinx.coroutines.test.TestScope
9+import kotlinx.coroutines.test.resetMain
10+import kotlinx.coroutines.test.runTest
11+import kotlinx.coroutines.test.setMain
12+import org.junit.After
13+import org.junit.Assert.assertEquals
14+import org.junit.Assert.assertFalse
15+import org.junit.Assert.assertNotNull
16+import org.junit.Assert.assertNull
17+import org.junit.Assert.assertTrue
18+import org.junit.Before
20+import soy.iko.opencode.data.model.Command
21+import soy.iko.opencode.data.model.ModelInfo
22+import soy.iko.opencode.data.model.Permission
23+import soy.iko.opencode.data.model.PermissionResponse
24+import soy.iko.opencode.data.model.PermissionUpdated
25+import soy.iko.opencode.data.model.Provider
26+import soy.iko.opencode.data.model.ProvidersResponse
27+import soy.iko.opencode.data.model.ServerProfile
28+import soy.iko.opencode.data.model.Session
29+import soy.iko.opencode.data.model.SessionDeleted
30+import soy.iko.opencode.data.model.SessionError
31+import soy.iko.opencode.data.model.SessionIdle
32+import soy.iko.opencode.data.model.SessionUpdated
33+import soy.iko.opencode.data.network.EventStreamClient
34+import soy.iko.opencode.ui.testing.FakeAppContainer
35+import soy.iko.opencode.ui.testing.FakeEventStreamClient
36+import soy.iko.opencode.ui.testing.FakeOpencodeApiClient
37+import soy.iko.opencode.ui.testing.FakeOpencodeConnection
38+import soy.iko.opencode.ui.testing.FakeSessionRepository
39+import java.io.IOException
41+class 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"),
60+ ): FakeAppContainer {
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)