@@ -0,0 +1,481 @@
1+@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
2+
3+package soy.iko.opencode.ui.chat
4+
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
19+import org.junit.Test
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
40+
41+class ChatViewModelTest {
42+ private val testDispatcher = StandardTestDispatcher()
43+ private val testScope = TestScope(testDispatcher)
44+
45+ @Before
46+ fun setUp() {
47+ Dispatchers.setMain(testDispatcher)
48+ }
49+
50+ @After
51+ fun tearDown() {
52+ Dispatchers.resetMain()
53+ }
54+
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+ }
66+
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+ }
73+
74+ // --- send() ---
75+
76+ @Test
77+ fun send_returnsFalseWhenNotConnected() = testScope.runTest {
78+ val container = FakeAppContainer()
79+ val vm = makeVm(container)
80+ assertFalse(vm.send("hello"))
81+ }
82+
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+ }
90+
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+ }
98+
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+ }
108+
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+ }
120+
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+ }
137+
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+ }
152+
153+ // --- retryFailed() ---
154+
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+ }
163+
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)
176+
177+ repo.sendPromptThrows = null
178+ vm.retryFailed()
179+ testScope.testScheduler.advanceUntilIdle()
180+ assertNull(vm.failedDraft.value)
181+ }
182+
183+ // --- runCommand() ---
184+
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+ }
192+
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+ }
203+
204+ // --- abort() ---
205+
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+ }
216+
217+ // --- respondPermission() ---
218+
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")
225+
226+ events.fakeEvents.tryEmit(PermissionUpdated(perm))
227+ testScope.testScheduler.advanceUntilIdle()
228+ assertEquals("p1", vm.pendingPermission.value?.id)
229+
230+ vm.respondPermission(perm, PermissionResponse.ONCE)
231+ assertNull(vm.pendingPermission.value)
232+ }
233+
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")
241+
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)
246+
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+ }
253+
254+ // --- SSE event handling ---
255+
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+ }
267+
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+ }
280+
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+ }
292+
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+ }
304+
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+ }
316+
317+ // --- Connection state ---
318+
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+ }
330+
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+ }
342+
343+ // --- Draft persistence ---
344+
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+ }
353+
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+ }
362+
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+ }
375+
376+ // --- Catalog loading ---
377+
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+ }
390+
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)
398+
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+ }
409+
410+ // --- reconnect() ---
411+
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+ }
424+
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+ }
433+
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+ }
442+
443+ // --- Connection reset on server switch ---
444+
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+ }
463+
464+ // --- clearError ---
465+
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+ }
481+}