1@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
3package soy.iko.opencode.ui.file
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.ExperimentalCoroutinesApi
7import kotlinx.coroutines.flow.toList
8import kotlinx.coroutines.test.StandardTestDispatcher
9import kotlinx.coroutines.test.TestScope
10import kotlinx.coroutines.test.resetMain
11import kotlinx.coroutines.test.runTest
12import kotlinx.coroutines.test.setMain
13import kotlinx.coroutines.launch
15import org.junit.Assert.assertEquals
16import org.junit.Assert.assertFalse
17import org.junit.Assert.assertNotNull
18import org.junit.Assert.assertNull
19import org.junit.Assert.assertTrue
20import org.junit.Before
22import java.io.IOException
23import soy.iko.opencode.data.model.FileNode
24import soy.iko.opencode.data.model.FileStatusEntry
25import soy.iko.opencode.data.model.ServerProfile
26import soy.iko.opencode.ui.testing.FakeAppContainer
27import soy.iko.opencode.ui.testing.FakeEventStreamClient
28import soy.iko.opencode.ui.testing.FakeOpencodeApiClient
29import soy.iko.opencode.ui.testing.FakeOpencodeConnection
30import soy.iko.opencode.ui.testing.FakeSessionRepository
32class FileBrowserViewModelTest {
33 private val testDispatcher = StandardTestDispatcher()
34 private val testScope = TestScope(testDispatcher)
38 Dispatchers.setMain(testDispatcher)
43 Dispatchers.resetMain()
46 private fun makeContainer(
47 api: FakeOpencodeApiClient = FakeOpencodeApiClient(),
49 val events = FakeEventStreamClient()
50 val repo = FakeSessionRepository(api, events)
51 val container = FakeAppContainer()
52 val conn = FakeOpencodeConnection(api, events, repo, ServerProfile(id = "s1", label = "Test", baseUrl = "http://localhost"))
53 container.setActiveConnection(conn)
57 private fun makeVm(container: FakeAppContainer): FileBrowserViewModel {
58 val vm = FileBrowserViewModel(container)
59 testScope.testScheduler.advanceUntilIdle()
66 fun open_loadsDirectoryListing() = testScope.runTest {
67 val api = FakeOpencodeApiClient()
68 api.directoryListing = listOf(
69 FileNode(name = "file1.txt", path = "file1.txt", type = "file"),
70 FileNode(name = "dir1", path = "dir1", type = "directory"),
72 val container = makeContainer(api)
73 val vm = makeVm(container)
74 assertFalse(vm.state.value.loading)
75 assertEquals(2, vm.state.value.entries.size)
76 // Directories sorted first
77 assertTrue(vm.state.value.entries[0].isDirectory)
81 fun open_withoutConnection_showsNotConnected() = testScope.runTest {
82 val container = FakeAppContainer()
83 val vm = makeVm(container)
84 assertFalse(vm.state.value.loading)
85 assertNotNull(vm.state.value.error)
89 fun `open loads and sorts directories first`() = testScope.runTest {
90 val api = FakeOpencodeApiClient()
91 api.directoryListing = listOf(
92 FileNode(name = "zfile.txt", path = "zfile.txt", type = "file"),
93 FileNode(name = "adir", path = "adir", type = "directory"),
94 FileNode(name = "bfile.txt", path = "bfile.txt", type = "file"),
96 val container = makeContainer(api)
97 val vm = makeVm(container)
98 val entries = vm.state.value.entries
99 assertEquals("adir", entries[0].name)
105 fun up_navigatesToParentDirectory() = testScope.runTest {
106 val api = FakeOpencodeApiClient()
107 api.directoryListing = listOf(FileNode(name = "parent", path = "", type = "directory"))
108 val container = makeContainer(api)
109 val vm = makeVm(container)
111 testScheduler.advanceUntilIdle()
113 testScheduler.advanceUntilIdle()
114 assertEquals("foo", vm.state.value.path)
118 fun up_atRootDoesNothing() = testScope.runTest {
119 val container = makeContainer()
120 val vm = makeVm(container)
122 assertEquals("", vm.state.value.path)
125 // --- setQuery() / search ---
128 fun setQuery_blankClearsSearch() = testScope.runTest {
129 val api = FakeOpencodeApiClient()
130 api.directoryListing = listOf(FileNode(name = "file.txt", path = "file.txt", type = "file"))
131 val container = makeContainer(api)
132 val vm = makeVm(container)
134 testScheduler.advanceUntilIdle()
135 assertTrue(vm.state.value.isSearching)
137 testScheduler.advanceUntilIdle()
138 assertFalse(vm.state.value.isSearching)
139 assertFalse(vm.state.value.searching)
143 fun setQuery_nonBlankTriggersSearchAfterDebounce() = testScope.runTest {
144 val api = FakeOpencodeApiClient()
145 api.fileSearchResults = listOf("src/main.kt", "src/util.kt")
146 val container = makeContainer(api)
147 val vm = makeVm(container)
149 // Immediately searching flag is set
150 assertTrue(vm.state.value.searching)
151 // But results are empty until debounce fires
152 assertTrue(vm.state.value.results.isEmpty())
153 testScheduler.advanceUntilIdle()
154 // After debounce, results are populated
155 assertEquals(2, vm.state.value.results.size)
156 assertFalse(vm.state.value.searching)
160 fun setQuery_debounceCoalescesRapidTyping() = testScope.runTest {
161 val api = FakeOpencodeApiClient()
162 api.fileSearchResults = listOf("result.kt")
163 val container = makeContainer(api)
164 val vm = makeVm(container)
168 testScheduler.advanceUntilIdle()
169 // Only one search should have been performed (debounced)
170 assertEquals(1, api.fileSearchResults.size) // fake returns same results, just verify it ran
171 assertFalse(vm.state.value.searching)
174 // --- VCS status ---
177 fun loadStatus_loadsVcsStatus() = testScope.runTest {
178 val api = FakeOpencodeApiClient()
179 api.fileStatusEntries = listOf(
180 FileStatusEntry(path = "src/main.kt", status = "M"),
182 val container = makeContainer(api)
183 val vm = makeVm(container)
184 assertEquals(1, vm.state.value.statusMap.size)
185 assertEquals("M", vm.state.value.statusMap["src/main.kt"]?.status)
189 fun loadStatus_failureSetsTransientError() = testScope.runTest {
190 val api = FakeOpencodeApiClient()
191 api.fileStatusThrows = IOException("network error")
192 val container = makeContainer(api)
193 // Collect transient errors BEFORE constructing the VM: loadStatus() runs from the
194 // VM's init (via collectLatest on activeConnection), so the failure + emit happens
195 // during makeVm's advanceUntilIdle. Starting the collector first captures it.
196 val errors = mutableListOf<String>()
197 // A placeholder VM is needed to access transientErrors, but the VM must be created
198 // with the collector already running. Create the VM without advancing, start the
199 // collector, then advance.
200 val vm = FileBrowserViewModel(container)
201 val collectJob = launch { vm.transientErrors.toList(errors) }
202 testScheduler.advanceUntilIdle()
204 assertTrue("expected a transient error on fileStatus failure", errors.isNotEmpty())
207 // --- connection availability ---
210 fun open_retriesWhenConnectionBecomesAvailable() = testScope.runTest {
211 val container = FakeAppContainer()
212 val vm = FileBrowserViewModel(container)
213 testScheduler.advanceUntilIdle()
214 // Initially no connection — should show error
215 assertNotNull(vm.state.value.error)
217 // Connection becomes available
218 val api = FakeOpencodeApiClient()
219 api.directoryListing = listOf(FileNode(name = "file.txt", path = "file.txt", type = "file"))
220 val events = FakeEventStreamClient()
221 val repo = FakeSessionRepository(api, events)
222 val conn = FakeOpencodeConnection(api, events, repo, ServerProfile(id = "s1", label = "Test", baseUrl = "http://localhost"))
223 container.setActiveConnection(conn)
224 testScheduler.advanceUntilIdle()
226 // Should now have loaded the directory
227 assertFalse(vm.state.value.loading)
228 assertNull(vm.state.value.error)
229 assertEquals(1, vm.state.value.entries.size)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list