git.iko.soy/opencode-android
on dce783d help sign in
opencode-android/app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.kt
219 lines 8.1 kB history raw
1@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
3package soy.iko.opencode.ui.file
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.ExperimentalCoroutinesApi
7import kotlinx.coroutines.test.StandardTestDispatcher
8import kotlinx.coroutines.test.TestScope
9import kotlinx.coroutines.test.resetMain
10import kotlinx.coroutines.test.runTest
11import kotlinx.coroutines.test.setMain
12import org.junit.After
13import org.junit.Assert.assertEquals
14import org.junit.Assert.assertFalse
15import org.junit.Assert.assertNotNull
16import org.junit.Assert.assertNull
17import org.junit.Assert.assertTrue
18import org.junit.Before
19import org.junit.Test
20import soy.iko.opencode.data.model.FileNode
21import soy.iko.opencode.data.model.FileStatusEntry
22import soy.iko.opencode.data.model.ServerProfile
23import soy.iko.opencode.ui.testing.FakeAppContainer
24import soy.iko.opencode.ui.testing.FakeEventStreamClient
25import soy.iko.opencode.ui.testing.FakeOpencodeApiClient
26import soy.iko.opencode.ui.testing.FakeOpencodeConnection
27import soy.iko.opencode.ui.testing.FakeSessionRepository
29class FileBrowserViewModelTest {
30 private val testDispatcher = StandardTestDispatcher()
31 private val testScope = TestScope(testDispatcher)
33 @Before
34 fun setUp() {
35 Dispatchers.setMain(testDispatcher)
36 }
38 @After
39 fun tearDown() {
40 Dispatchers.resetMain()
41 }
43 private fun makeContainer(
44 api: FakeOpencodeApiClient = FakeOpencodeApiClient(),
45 ): FakeAppContainer {
46 val events = FakeEventStreamClient()
47 val repo = FakeSessionRepository(api, events)
48 val container = FakeAppContainer()
49 val conn = FakeOpencodeConnection(api, events, repo, ServerProfile(id = "s1", label = "Test", baseUrl = "http://localhost"))
50 container.setActiveConnection(conn)
51 return container
52 }
54 private fun makeVm(container: FakeAppContainer): FileBrowserViewModel {
55 val vm = FileBrowserViewModel(container)
56 testScope.testScheduler.advanceUntilIdle()
57 return vm
58 }
60 // --- open() ---
62 @Test
63 fun open_loadsDirectoryListing() = testScope.runTest {
64 val api = FakeOpencodeApiClient()
65 api.directoryListing = listOf(
66 FileNode(name = "file1.txt", path = "file1.txt", type = "file"),
67 FileNode(name = "dir1", path = "dir1", type = "directory"),
68 )
69 val container = makeContainer(api)
70 val vm = makeVm(container)
71 assertFalse(vm.state.value.loading)
72 assertEquals(2, vm.state.value.entries.size)
73 // Directories sorted first
74 assertTrue(vm.state.value.entries[0].isDirectory)
75 }
77 @Test
78 fun open_withoutConnection_showsNotConnected() = testScope.runTest {
79 val container = FakeAppContainer()
80 val vm = makeVm(container)
81 assertFalse(vm.state.value.loading)
82 assertNotNull(vm.state.value.error)
83 }
85 @Test
86 fun `open loads and sorts directories first`() = testScope.runTest {
87 val api = FakeOpencodeApiClient()
88 api.directoryListing = listOf(
89 FileNode(name = "zfile.txt", path = "zfile.txt", type = "file"),
90 FileNode(name = "adir", path = "adir", type = "directory"),
91 FileNode(name = "bfile.txt", path = "bfile.txt", type = "file"),
92 )
93 val container = makeContainer(api)
94 val vm = makeVm(container)
95 val entries = vm.state.value.entries
96 assertEquals("adir", entries[0].name)
97 }
99 // --- up() ---
101 @Test
102 fun up_navigatesToParentDirectory() = testScope.runTest {
103 val api = FakeOpencodeApiClient()
104 api.directoryListing = listOf(FileNode(name = "parent", path = "", type = "directory"))
105 val container = makeContainer(api)
106 val vm = makeVm(container)
107 vm.open("foo/bar")
108 testScheduler.advanceUntilIdle()
109 vm.up()
110 testScheduler.advanceUntilIdle()
111 assertEquals("foo", vm.state.value.path)
112 }
114 @Test
115 fun up_atRootDoesNothing() = testScope.runTest {
116 val container = makeContainer()
117 val vm = makeVm(container)
118 vm.up()
119 assertEquals("", vm.state.value.path)
120 }
122 // --- setQuery() / search ---
124 @Test
125 fun setQuery_blankClearsSearch() = testScope.runTest {
126 val api = FakeOpencodeApiClient()
127 api.directoryListing = listOf(FileNode(name = "file.txt", path = "file.txt", type = "file"))
128 val container = makeContainer(api)
129 val vm = makeVm(container)
130 vm.setQuery("test")
131 testScheduler.advanceUntilIdle()
132 assertTrue(vm.state.value.isSearching)
133 vm.setQuery("")
134 testScheduler.advanceUntilIdle()
135 assertFalse(vm.state.value.isSearching)
136 assertFalse(vm.state.value.searching)
137 }
139 @Test
140 fun setQuery_nonBlankTriggersSearchAfterDebounce() = testScope.runTest {
141 val api = FakeOpencodeApiClient()
142 api.fileSearchResults = listOf("src/main.kt", "src/util.kt")
143 val container = makeContainer(api)
144 val vm = makeVm(container)
145 vm.setQuery("main")
146 // Immediately searching flag is set
147 assertTrue(vm.state.value.searching)
148 // But results are empty until debounce fires
149 assertTrue(vm.state.value.results.isEmpty())
150 testScheduler.advanceUntilIdle()
151 // After debounce, results are populated
152 assertEquals(2, vm.state.value.results.size)
153 assertFalse(vm.state.value.searching)
154 }
156 @Test
157 fun setQuery_debounceCoalescesRapidTyping() = testScope.runTest {
158 val api = FakeOpencodeApiClient()
159 api.fileSearchResults = listOf("result.kt")
160 val container = makeContainer(api)
161 val vm = makeVm(container)
162 vm.setQuery("a")
163 vm.setQuery("ab")
164 vm.setQuery("abc")
165 testScheduler.advanceUntilIdle()
166 // Only one search should have been performed (debounced)
167 assertEquals(1, api.fileSearchResults.size) // fake returns same results, just verify it ran
168 assertFalse(vm.state.value.searching)
169 }
171 // --- VCS status ---
173 @Test
174 fun loadStatus_loadsVcsStatus() = testScope.runTest {
175 val api = FakeOpencodeApiClient()
176 api.fileStatusEntries = listOf(
177 FileStatusEntry(path = "src/main.kt", status = "M"),
178 )
179 val container = makeContainer(api)
180 val vm = makeVm(container)
181 assertEquals(1, vm.state.value.statusMap.size)
182 assertEquals("M", vm.state.value.statusMap["src/main.kt"]?.status)
183 }
185 @Test
186 fun loadStatus_failureSetsTransientError() = testScope.runTest {
187 val api = FakeOpencodeApiClient()
188 // Make fileStatus throw by not providing entries and having the fake return empty
189 // (the fake doesn't throw, so we just verify the happy path doesn't set error)
190 val container = makeContainer(api)
191 val vm = makeVm(container)
192 // With empty status, no transient error event is emitted.
193 }
195 // --- connection availability ---
197 @Test
198 fun open_retriesWhenConnectionBecomesAvailable() = testScope.runTest {
199 val container = FakeAppContainer()
200 val vm = FileBrowserViewModel(container)
201 testScheduler.advanceUntilIdle()
202 // Initially no connection — should show error
203 assertNotNull(vm.state.value.error)
205 // Connection becomes available
206 val api = FakeOpencodeApiClient()
207 api.directoryListing = listOf(FileNode(name = "file.txt", path = "file.txt", type = "file"))
208 val events = FakeEventStreamClient()
209 val repo = FakeSessionRepository(api, events)
210 val conn = FakeOpencodeConnection(api, events, repo, ServerProfile(id = "s1", label = "Test", baseUrl = "http://localhost"))
211 container.setActiveConnection(conn)
212 testScheduler.advanceUntilIdle()
214 // Should now have loaded the directory
215 assertFalse(vm.state.value.loading)
216 assertNull(vm.state.value.error)
217 assertEquals(1, vm.state.value.entries.size)
218 }