@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) package soy.iko.opencode.ui.file import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.toList 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 kotlinx.coroutines.launch 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 java.io.IOException import soy.iko.opencode.data.model.FileNode import soy.iko.opencode.data.model.FileStatusEntry import soy.iko.opencode.data.model.ServerProfile 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 class FileBrowserViewModelTest { 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(), ): FakeAppContainer { val events = FakeEventStreamClient() val repo = FakeSessionRepository(api, events) val container = FakeAppContainer() val conn = FakeOpencodeConnection(api, events, repo, ServerProfile(id = "s1", label = "Test", baseUrl = "http://localhost")) container.setActiveConnection(conn) return container } private fun makeVm(container: FakeAppContainer): FileBrowserViewModel { val vm = FileBrowserViewModel(container) testScope.testScheduler.advanceUntilIdle() return vm } // --- open() --- @Test fun open_loadsDirectoryListing() = testScope.runTest { val api = FakeOpencodeApiClient() api.directoryListing = listOf( FileNode(name = "file1.txt", path = "file1.txt", type = "file"), FileNode(name = "dir1", path = "dir1", type = "directory"), ) val container = makeContainer(api) val vm = makeVm(container) assertFalse(vm.state.value.loading) assertEquals(2, vm.state.value.entries.size) // Directories sorted first assertTrue(vm.state.value.entries[0].isDirectory) } @Test fun open_withoutConnection_showsNotConnected() = testScope.runTest { val container = FakeAppContainer() val vm = makeVm(container) assertFalse(vm.state.value.loading) assertNotNull(vm.state.value.error) } @Test fun `open loads and sorts directories first`() = testScope.runTest { val api = FakeOpencodeApiClient() api.directoryListing = listOf( FileNode(name = "zfile.txt", path = "zfile.txt", type = "file"), FileNode(name = "adir", path = "adir", type = "directory"), FileNode(name = "bfile.txt", path = "bfile.txt", type = "file"), ) val container = makeContainer(api) val vm = makeVm(container) val entries = vm.state.value.entries assertEquals("adir", entries[0].name) } // --- up() --- @Test fun up_navigatesToParentDirectory() = testScope.runTest { val api = FakeOpencodeApiClient() api.directoryListing = listOf(FileNode(name = "parent", path = "", type = "directory")) val container = makeContainer(api) val vm = makeVm(container) vm.open("foo/bar") testScheduler.advanceUntilIdle() vm.up() testScheduler.advanceUntilIdle() assertEquals("foo", vm.state.value.path) } @Test fun up_atRootDoesNothing() = testScope.runTest { val container = makeContainer() val vm = makeVm(container) vm.up() assertEquals("", vm.state.value.path) } // --- setQuery() / search --- @Test fun setQuery_blankClearsSearch() = testScope.runTest { val api = FakeOpencodeApiClient() api.directoryListing = listOf(FileNode(name = "file.txt", path = "file.txt", type = "file")) val container = makeContainer(api) val vm = makeVm(container) vm.setQuery("test") testScheduler.advanceUntilIdle() assertTrue(vm.state.value.isSearching) vm.setQuery("") testScheduler.advanceUntilIdle() assertFalse(vm.state.value.isSearching) assertFalse(vm.state.value.searching) } @Test fun setQuery_nonBlankTriggersSearchAfterDebounce() = testScope.runTest { val api = FakeOpencodeApiClient() api.fileSearchResults = listOf("src/main.kt", "src/util.kt") val container = makeContainer(api) val vm = makeVm(container) vm.setQuery("main") // Immediately searching flag is set assertTrue(vm.state.value.searching) // But results are empty until debounce fires assertTrue(vm.state.value.results.isEmpty()) testScheduler.advanceUntilIdle() // After debounce, results are populated assertEquals(2, vm.state.value.results.size) assertFalse(vm.state.value.searching) } @Test fun setQuery_debounceCoalescesRapidTyping() = testScope.runTest { val api = FakeOpencodeApiClient() api.fileSearchResults = listOf("result.kt") val container = makeContainer(api) val vm = makeVm(container) vm.setQuery("a") vm.setQuery("ab") vm.setQuery("abc") testScheduler.advanceUntilIdle() // Only one search should have been performed (debounced) assertEquals(1, api.fileSearchResults.size) // fake returns same results, just verify it ran assertFalse(vm.state.value.searching) } // --- VCS status --- @Test fun loadStatus_loadsVcsStatus() = testScope.runTest { val api = FakeOpencodeApiClient() api.fileStatusEntries = listOf( FileStatusEntry(path = "src/main.kt", status = "M"), ) val container = makeContainer(api) val vm = makeVm(container) assertEquals(1, vm.state.value.statusMap.size) assertEquals("M", vm.state.value.statusMap["src/main.kt"]?.status) } @Test fun loadStatus_failureSetsTransientError() = testScope.runTest { val api = FakeOpencodeApiClient() api.fileStatusThrows = IOException("network error") val container = makeContainer(api) // Collect transient errors BEFORE constructing the VM: loadStatus() runs from the // VM's init (via collectLatest on activeConnection), so the failure + emit happens // during makeVm's advanceUntilIdle. Starting the collector first captures it. val errors = mutableListOf() // A placeholder VM is needed to access transientErrors, but the VM must be created // with the collector already running. Create the VM without advancing, start the // collector, then advance. val vm = FileBrowserViewModel(container) val collectJob = launch { vm.transientErrors.toList(errors) } testScheduler.advanceUntilIdle() collectJob.cancel() assertTrue("expected a transient error on fileStatus failure", errors.isNotEmpty()) } // --- connection availability --- @Test fun open_retriesWhenConnectionBecomesAvailable() = testScope.runTest { val container = FakeAppContainer() val vm = FileBrowserViewModel(container) testScheduler.advanceUntilIdle() // Initially no connection — should show error assertNotNull(vm.state.value.error) // Connection becomes available val api = FakeOpencodeApiClient() api.directoryListing = listOf(FileNode(name = "file.txt", path = "file.txt", type = "file")) val events = FakeEventStreamClient() val repo = FakeSessionRepository(api, events) val conn = FakeOpencodeConnection(api, events, repo, ServerProfile(id = "s1", label = "Test", baseUrl = "http://localhost")) container.setActiveConnection(conn) testScheduler.advanceUntilIdle() // Should now have loaded the directory assertFalse(vm.state.value.loading) assertNull(vm.state.value.error) assertEquals(1, vm.state.value.entries.size) } }