@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class) package soy.iko.opencode.ui.chat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import soy.iko.opencode.data.model.Command import soy.iko.opencode.data.model.ModelInfo import soy.iko.opencode.data.model.Permission import soy.iko.opencode.data.model.PermissionResponse import soy.iko.opencode.data.model.PermissionUpdated import soy.iko.opencode.data.model.Provider import soy.iko.opencode.data.model.ProvidersResponse import soy.iko.opencode.data.model.ServerProfile import soy.iko.opencode.data.model.Session import soy.iko.opencode.data.model.SessionDeleted import soy.iko.opencode.data.model.SessionError import soy.iko.opencode.data.model.SessionIdle import soy.iko.opencode.data.model.SessionUpdated import soy.iko.opencode.data.network.EventStreamClient import soy.iko.opencode.ui.testing.FakeAppContainer import soy.iko.opencode.ui.testing.FakeEventStreamClient import soy.iko.opencode.ui.testing.FakeOpencodeApiClient import soy.iko.opencode.ui.testing.FakeOpencodeConnection import soy.iko.opencode.ui.testing.FakeSessionRepository import java.io.IOException class ChatViewModelTest { private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) @Before fun setUp() { Dispatchers.setMain(testDispatcher) } @After fun tearDown() { Dispatchers.resetMain() } private fun makeContainer( api: FakeOpencodeApiClient = FakeOpencodeApiClient(), events: FakeEventStreamClient = FakeEventStreamClient(), repo: FakeSessionRepository = FakeSessionRepository(api, events), profile: ServerProfile = ServerProfile(id = "s1", label = "Test", baseUrl = "http://localhost"), ): FakeAppContainer { val container = FakeAppContainer() val conn = FakeOpencodeConnection(api, events, repo, profile) container.setActiveConnection(conn) return container } /** Creates a ViewModel and lets its init coroutines start before returning. */ private fun makeVm(container: FakeAppContainer, sessionId: String = "session1"): ChatViewModel { val vm = ChatViewModel(container, sessionId) testScope.testScheduler.advanceUntilIdle() return vm } // --- send() --- @Test fun send_returnsFalseWhenNotConnected() = testScope.runTest { val container = FakeAppContainer() val vm = makeVm(container) assertFalse(vm.send("hello")) } @Test fun send_returnsFalseForEmptyText() = testScope.runTest { val container = makeContainer() val vm = makeVm(container) assertFalse(vm.send("")) assertFalse(vm.send(" ")) } @Test fun send_returnsFalseWhenAlreadyRunning() = testScope.runTest { val container = makeContainer() val vm = makeVm(container) assertTrue(vm.send("first")) assertFalse(vm.send("second")) } @Test fun send_clearsDraftImmediatelyAndSetsRunning() = testScope.runTest { val container = makeContainer() val vm = makeVm(container) vm.updateDraft("hello world") assertTrue(vm.send("hello world")) assertEquals("", vm.draft.value) assertTrue(vm.running.value) } @Test fun send_successPersistsEmptyDraft() = testScope.runTest { val container = makeContainer() val vm = makeVm(container) vm.updateDraft("hello") assertTrue(vm.send("hello")) testScope.testScheduler.advanceUntilIdle() assertEquals("", container.fakeDraftStore.get("session1")) assertNull(vm.failedDraft.value) assertNull(vm.error.value) } @Test fun send_failureRestoresDraftAndClearsRunning() = testScope.runTest { val api = FakeOpencodeApiClient() val events = FakeEventStreamClient() val repo = FakeSessionRepository(api, events) repo.sendPromptThrows = IOException("network error") val container = makeContainer(api = api, events = events, repo = repo) val vm = makeVm(container) vm.updateDraft("hello") assertTrue(vm.send("hello")) testScope.testScheduler.advanceUntilIdle() assertEquals("hello", vm.draft.value) assertEquals("hello", vm.failedDraft.value) assertFalse(vm.running.value) assertNotNull(vm.error.value) } @Test fun send_failureDoesNotRestoreDraftIfUserTypedNewText() = testScope.runTest { val api = FakeOpencodeApiClient() val events = FakeEventStreamClient() val repo = FakeSessionRepository(api, events) repo.sendPromptThrows = IOException("network error") val container = makeContainer(api = api, events = events, repo = repo) val vm = makeVm(container) vm.updateDraft("original") assertTrue(vm.send("original")) vm.updateDraft("new text") testScope.testScheduler.advanceUntilIdle() assertEquals("new text", vm.draft.value) } // --- retryFailed() --- @Test fun retryFailed_noopWhenNoFailedDraft() = testScope.runTest { val container = makeContainer() val vm = makeVm(container) vm.retryFailed() testScope.testScheduler.advanceUntilIdle() assertFalse(vm.running.value) } @Test fun retryFailed_resendsFailedDraft() = testScope.runTest { val api = FakeOpencodeApiClient() val events = FakeEventStreamClient() val repo = FakeSessionRepository(api, events) repo.sendPromptThrows = IOException("network error") val container = makeContainer(api = api, events = events, repo = repo) val vm = makeVm(container) vm.updateDraft("hello") assertTrue(vm.send("hello")) testScope.testScheduler.advanceUntilIdle() assertEquals("hello", vm.failedDraft.value) repo.sendPromptThrows = null vm.retryFailed() testScope.testScheduler.advanceUntilIdle() assertNull(vm.failedDraft.value) } // --- runCommand() --- @Test fun runCommand_setsRunning() = testScope.runTest { val container = makeContainer() val vm = makeVm(container) vm.runCommand(Command(name = "compact")) assertTrue(vm.running.value) } @Test fun runCommand_rejectedWhenAlreadyRunning() = testScope.runTest { val container = makeContainer() val vm = makeVm(container) assertTrue(vm.send("hello")) assertFalse(vm.running.value.not()) // running is true vm.runCommand(Command(name = "compact")) // Still running from the first send; runCommand should not override assertTrue(vm.running.value) } // --- abort() --- @Test fun abort_clearsRunning() = testScope.runTest { val container = makeContainer() val vm = makeVm(container) assertTrue(vm.send("hello")) testScope.testScheduler.advanceUntilIdle() vm.abort() testScope.testScheduler.advanceUntilIdle() assertFalse(vm.running.value) } // --- respondPermission() --- @Test fun respondPermission_clearsPendingOptimistically() = testScope.runTest { val events = FakeEventStreamClient() val container = makeContainer(events = events) val vm = makeVm(container) val perm = Permission(id = "p1", sessionID = "session1") events.fakeEvents.tryEmit(PermissionUpdated(perm)) testScope.testScheduler.advanceUntilIdle() assertEquals("p1", vm.pendingPermission.value?.id) vm.respondPermission(perm, PermissionResponse.ONCE) assertNull(vm.pendingPermission.value) } @Test fun respondPermission_restoresPendingOnFailure() = testScope.runTest { val api = FakeOpencodeApiClient() api.respondPermissionThrows = IOException("network error") val container = makeContainer(api = api) val vm = makeVm(container) val perm = Permission(id = "p1", sessionID = "session1") val events = (container.activeConnection.value!!.events as FakeEventStreamClient) events.fakeEvents.tryEmit(PermissionUpdated(perm)) testScope.testScheduler.advanceUntilIdle() assertEquals("p1", vm.pendingPermission.value?.id) vm.respondPermission(perm, PermissionResponse.ONCE) assertNull(vm.pendingPermission.value) testScope.testScheduler.advanceUntilIdle() assertEquals("p1", vm.pendingPermission.value?.id) assertNotNull(vm.error.value) } // --- SSE event handling --- @Test fun sseSessionIdle_resetsRunning() = testScope.runTest { val events = FakeEventStreamClient() val container = makeContainer(events = events) val vm = makeVm(container) assertTrue(vm.send("hello")) testScope.testScheduler.advanceUntilIdle() events.fakeEvents.tryEmit(SessionIdle(SessionIdle.Props(sessionID = "session1"))) testScope.testScheduler.advanceUntilIdle() assertFalse(vm.running.value) } @Test fun sseSessionError_resetsRunningAndSetsError() = testScope.runTest { val events = FakeEventStreamClient() val container = makeContainer(events = events) val vm = makeVm(container) assertTrue(vm.send("hello")) testScope.testScheduler.advanceUntilIdle() events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1"))) testScope.testScheduler.advanceUntilIdle() assertFalse(vm.running.value) assertNotNull(vm.error.value) } @Test fun sseSessionIdle_ignoresOtherSession() = testScope.runTest { val events = FakeEventStreamClient() val container = makeContainer(events = events) val vm = makeVm(container) assertTrue(vm.send("hello")) testScope.testScheduler.advanceUntilIdle() events.fakeEvents.tryEmit(SessionIdle(SessionIdle.Props(sessionID = "other"))) testScope.testScheduler.advanceUntilIdle() assertTrue(vm.running.value) } @Test fun sseSessionUpdated_updatesTitle() = testScope.runTest { val events = FakeEventStreamClient() val container = makeContainer(events = events) val vm = makeVm(container) events.fakeEvents.tryEmit( SessionUpdated(SessionUpdated.Props(info = Session(id = "session1", title = "My Chat"))), ) testScope.testScheduler.advanceUntilIdle() assertEquals("My Chat", vm.sessionTitle.value) } @Test fun sseSessionDeleted_setsDeletedFlag() = testScope.runTest { val events = FakeEventStreamClient() val container = makeContainer(events = events) val vm = makeVm(container) events.fakeEvents.tryEmit( SessionDeleted(SessionDeleted.Props(sessionID = "session1")), ) testScope.testScheduler.advanceUntilIdle() assertTrue(vm.sessionDeleted.value) } // --- Connection state --- @Test fun sseDisconnected_resetsRunning() = testScope.runTest { val events = FakeEventStreamClient() val container = makeContainer(events = events) val vm = makeVm(container) assertTrue(vm.send("hello")) testScope.testScheduler.advanceUntilIdle() events.fakeState.value = EventStreamClient.ConnectionState.Disconnected testScope.testScheduler.advanceUntilIdle() assertFalse(vm.running.value) } @Test fun sseFailed_resetsRunning() = testScope.runTest { val events = FakeEventStreamClient() val container = makeContainer(events = events) val vm = makeVm(container) assertTrue(vm.send("hello")) testScope.testScheduler.advanceUntilIdle() events.fakeState.value = EventStreamClient.ConnectionState.Failed testScope.testScheduler.advanceUntilIdle() assertFalse(vm.running.value) } // --- Draft persistence --- @Test fun updateDraft_doesNotPersistImmediately() = testScope.runTest { val container = makeContainer() val vm = makeVm(container) vm.updateDraft("typing") assertEquals("", container.fakeDraftStore.get("session1")) assertEquals("typing", vm.draft.value) } @Test fun updateDraft_persistsAfterDebounce() = testScope.runTest { val container = makeContainer() val vm = makeVm(container) vm.updateDraft("typing") testScope.testScheduler.advanceUntilIdle() assertEquals("typing", container.fakeDraftStore.get("session1")) } @Test fun onCleared_flushesPendingDraft() = testScope.runTest { val container = makeContainer() val vm = makeVm(container) vm.updateDraft("unsent draft") testScope.testScheduler.advanceUntilIdle() // onCleared() is protected — invoke via reflection val method = androidx.lifecycle.ViewModel::class.java.getDeclaredMethod("onCleared") method.isAccessible = true method.invoke(vm) assertEquals("unsent draft", container.fakeDraftStore.lastFlushed?.second) } // --- Catalog loading --- @Test fun catalogs_loadFromApi() = testScope.runTest { val api = FakeOpencodeApiClient() api.agentsList = listOf(soy.iko.opencode.data.model.Agent(name = "code")) api.commandsList = listOf(Command(name = "compact")) val container = makeContainer(api = api) val vm = makeVm(container) testScope.testScheduler.advanceUntilIdle() assertEquals(1, vm.agents.value.size) assertEquals("code", vm.agents.value[0].name) assertEquals(1, vm.commands.value.size) } @Test fun reloadModels_invalidatesCacheAndRefetches() = testScope.runTest { val api = FakeOpencodeApiClient() val container = makeContainer(api = api) val vm = makeVm(container) testScope.testScheduler.advanceUntilIdle() assertFalse(api.cacheInvalidated) api.providersResponse = ProvidersResponse( providers = listOf( Provider(id = "p1", name = "Provider 1", models = mapOf("m1" to ModelInfo(id = "m1", name = "Model 1"))), ), ) vm.reloadModels() testScope.testScheduler.advanceUntilIdle() assertTrue(api.cacheInvalidated) assertTrue(vm.models.value.isNotEmpty()) } // --- reconnect() --- @Test fun reconnect_connectsToMostRecentProfile() = testScope.runTest { val container = FakeAppContainer() container.fakeProfileStore.setProfiles( listOf(ServerProfile(id = "p1", label = "Server 1", baseUrl = "http://localhost")), ) val vm = makeVm(container) vm.reconnect() testScope.testScheduler.advanceUntilIdle() assertEquals(1, container.connectCalls.size) assertEquals("p1", container.connectCalls[0].id) } @Test fun reconnect_withoutProfilesDoesNothing() = testScope.runTest { val container = FakeAppContainer() val vm = makeVm(container) vm.reconnect() testScope.testScheduler.advanceUntilIdle() assertEquals(0, container.connectCalls.size) } @Test fun reconnect_skipsWhenAlreadyConnected() = testScope.runTest { val container = makeContainer() val vm = makeVm(container) vm.reconnect() testScope.testScheduler.advanceUntilIdle() assertEquals(0, container.connectCalls.size) } // --- Connection reset on server switch --- @Test fun connectionChange_resetsRunningAndErrorAndPermission() = testScope.runTest { val events = FakeEventStreamClient() val container = makeContainer(events = events) val vm = makeVm(container) assertTrue(vm.send("hello")) testScope.testScheduler.advanceUntilIdle() // Switch to a new connection val api2 = FakeOpencodeApiClient() val events2 = FakeEventStreamClient() val repo2 = FakeSessionRepository(api2, events2) val conn2 = FakeOpencodeConnection(api2, events2, repo2, ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other")) container.setActiveConnection(conn2) testScope.testScheduler.advanceUntilIdle() assertFalse(vm.running.value) assertNull(vm.error.value) assertNull(vm.pendingPermission.value) } // --- clearError --- @Test fun clearError_clearsError() = testScope.runTest { val api = FakeOpencodeApiClient() val events = FakeEventStreamClient() val repo = FakeSessionRepository(api, events) repo.sendPromptThrows = IOException("err") val container = makeContainer(api = api, events = events, repo = repo) val vm = makeVm(container) vm.updateDraft("hello") assertTrue(vm.send("hello")) testScope.testScheduler.advanceUntilIdle() assertNotNull(vm.error.value) vm.clearError() assertNull(vm.error.value) } }