git.iko.soy/opencode-android
on fac92bb help sign in
opencode-android/app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt
464 lines 17 kB history raw
1@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
3package soy.iko.opencode.ui.server
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.ServerProfile
21import soy.iko.opencode.di.ProbeResult
22import soy.iko.opencode.ui.testing.FakeAppContainer
24class ServerEditViewModelTest {
25 private val testDispatcher = StandardTestDispatcher()
26 private val testScope = TestScope(testDispatcher)
28 @Before
29 fun setUp() {
30 Dispatchers.setMain(testDispatcher)
31 }
33 @After
34 fun tearDown() {
35 Dispatchers.resetMain()
36 }
38 private fun makeVm(container: FakeAppContainer, profileId: String? = null): ServerEditViewModel {
39 val vm = ServerEditViewModel(container, profileId)
40 testScope.testScheduler.advanceUntilIdle()
41 return vm
42 }
44 // --- new profile ---
46 @Test
47 fun init_newProfile_loadsEmptyForm() = testScope.runTest {
48 val container = FakeAppContainer()
49 val vm = makeVm(container, profileId = null)
50 assertTrue(vm.state.value.loaded)
51 assertTrue(vm.state.value.isNew)
52 assertEquals("", vm.state.value.baseUrl)
53 assertEquals("", vm.state.value.label)
54 assertFalse(vm.state.value.saving)
55 assertNull(vm.state.value.error)
56 }
58 // --- existing profile ---
60 @Test
61 fun init_existingProfile_loadsFormFields() = testScope.runTest {
62 val container = FakeAppContainer()
63 val profile = ServerProfile(
64 id = "p1",
65 label = "My Server",
66 baseUrl = "http://localhost:3000",
67 username = "admin",
68 password = "secret",
69 )
70 container.fakeProfileStore.setProfiles(listOf(profile))
71 val vm = makeVm(container, profileId = "p1")
72 assertTrue(vm.state.value.loaded)
73 assertFalse(vm.state.value.isNew)
74 assertEquals("p1", vm.state.value.id)
75 assertEquals("My Server", vm.state.value.label)
76 assertEquals("http://localhost:3000", vm.state.value.baseUrl)
77 assertEquals("admin", vm.state.value.username)
78 assertEquals("secret", vm.state.value.password)
79 }
81 @Test
82 fun init_nonExistentProfile_showsError() = testScope.runTest {
83 val container = FakeAppContainer()
84 container.fakeProfileStore.setProfiles(emptyList())
85 val vm = makeVm(container, profileId = "nonexistent")
86 assertTrue(vm.state.value.loaded)
87 assertNotNull(vm.state.value.error)
88 }
90 // --- update() ---
92 @Test
93 fun update_modifiesState() = testScope.runTest {
94 val container = FakeAppContainer()
95 val vm = makeVm(container)
96 vm.update { it.copy(label = "New Label") }
97 assertEquals("New Label", vm.state.value.label)
98 }
100 // --- canSave validation ---
102 @Test
103 fun canSave_falseForBlankBaseUrl() = testScope.runTest {
104 val container = FakeAppContainer()
105 val vm = makeVm(container)
106 vm.update { it.copy(baseUrl = "") }
107 assertFalse(vm.state.value.canSave)
108 }
110 @Test
111 fun canSave_falseForInvalidUrl() = testScope.runTest {
112 val container = FakeAppContainer()
113 val vm = makeVm(container)
114 vm.update { it.copy(baseUrl = "not a url") }
115 assertFalse(vm.state.value.canSave)
116 }
118 @Test
119 fun canSave_trueForValidHttpUrl() = testScope.runTest {
120 val container = FakeAppContainer()
121 val vm = makeVm(container)
122 vm.update { it.copy(baseUrl = "http://localhost:3000") }
123 assertTrue(vm.state.value.canSave)
124 }
126 @Test
127 fun canSave_trueForValidHttpsUrl() = testScope.runTest {
128 val container = FakeAppContainer()
129 val vm = makeVm(container)
130 vm.update { it.copy(baseUrl = "https://example.com") }
131 assertTrue(vm.state.value.canSave)
132 }
134 @Test
135 fun canSave_falseForFtpUrl() = testScope.runTest {
136 val container = FakeAppContainer()
137 val vm = makeVm(container)
138 vm.update { it.copy(baseUrl = "ftp://example.com") }
139 assertFalse(vm.state.value.canSave)
140 }
142 // --- save() ---
144 @Test
145 fun save_persistsProfileAndCallsOnDone() = testScope.runTest {
146 val container = FakeAppContainer()
147 val vm = makeVm(container)
148 vm.update { it.copy(baseUrl = "http://localhost:3000", label = "Test") }
149 var doneCalled = false
150 vm.save { doneCalled = true }
151 testScheduler.advanceUntilIdle()
152 assertTrue(doneCalled)
153 assertNotNull(container.fakeProfileStore.savedProfile)
154 assertEquals("http://localhost:3000", container.fakeProfileStore.savedProfile?.baseUrl)
155 assertEquals("Test", container.fakeProfileStore.savedProfile?.label)
156 }
158 @Test
159 fun save_invalidUrlDoesNotSave() = testScope.runTest {
160 val container = FakeAppContainer()
161 val vm = makeVm(container)
162 vm.update { it.copy(baseUrl = "invalid") }
163 var doneCalled = false
164 vm.save { doneCalled = true }
165 testScheduler.advanceUntilIdle()
166 assertFalse(doneCalled)
167 assertNull(container.fakeProfileStore.savedProfile)
168 }
170 @Test
171 fun save_whileSavingDoesNotSaveAgain() = testScope.runTest {
172 val container = FakeAppContainer()
173 val vm = makeVm(container)
174 vm.update { it.copy(baseUrl = "http://localhost:3000") }
175 var doneCount = 0
176 vm.save { doneCount++ }
177 // While saving, try to save again
178 vm.save { doneCount++ }
179 testScheduler.advanceUntilIdle()
180 assertEquals(1, doneCount)
181 }
183 @Test
184 fun save_generatesIdForNewProfile() = testScope.runTest {
185 val container = FakeAppContainer()
186 val vm = makeVm(container)
187 vm.update { it.copy(baseUrl = "http://localhost:3000") }
188 vm.save { }
189 testScheduler.advanceUntilIdle()
190 val saved = container.fakeProfileStore.savedProfile
191 assertNotNull(saved?.id)
192 assertTrue(saved!!.id.isNotEmpty())
193 }
195 @Test
196 fun save_preservesIdForExistingProfile() = testScope.runTest {
197 val container = FakeAppContainer()
198 val profile = ServerProfile(id = "existing-id", label = "Old", baseUrl = "http://old")
199 container.fakeProfileStore.setProfiles(listOf(profile))
200 val vm = makeVm(container, profileId = "existing-id")
201 vm.update { it.copy(baseUrl = "http://new") }
202 vm.save { }
203 testScheduler.advanceUntilIdle()
204 assertEquals("existing-id", container.fakeProfileStore.savedProfile?.id)
205 }
207 @Test
208 fun save_trimsFields() = testScope.runTest {
209 val container = FakeAppContainer()
210 val vm = makeVm(container)
211 vm.update { it.copy(baseUrl = " http://localhost:3000 ", label = " Test ", username = " admin ") }
212 vm.save { }
213 testScheduler.advanceUntilIdle()
214 val saved = container.fakeProfileStore.savedProfile!!
215 assertEquals("http://localhost:3000", saved.baseUrl)
216 assertEquals("Test", saved.label)
217 assertEquals("admin", saved.username)
218 }
220 @Test
221 fun save_emptyUsernameClearsCredentials() = testScope.runTest {
222 val container = FakeAppContainer()
223 val vm = makeVm(container)
224 vm.update { it.copy(baseUrl = "http://localhost:3000", username = "", password = "") }
225 vm.save { }
226 testScheduler.advanceUntilIdle()
227 val saved = container.fakeProfileStore.savedProfile!!
228 assertNull(saved.username)
229 assertNull(saved.password)
230 }
232 @Test
233 fun save_failureSetsErrorAndClearsSaving() = testScope.runTest {
234 val container = FakeAppContainer()
235 container.fakeProfileStore.saveException = RuntimeException("disk full")
236 val vm = makeVm(container)
237 vm.update { it.copy(baseUrl = "http://localhost:3000") }
238 var doneCalled = false
239 vm.save { doneCalled = true }
240 testScheduler.advanceUntilIdle()
241 assertFalse(doneCalled)
242 assertFalse(vm.state.value.saving)
243 // The error must come from the exception, not from a clobbered ServerEditState.
244 assertNotNull(vm.state.value.error)
245 assertTrue(vm.state.value.error!!.contains("disk full"))
246 }
248 @Test
249 fun save_failureDoesNotClobberUserEdits() = testScope.runTest {
250 val container = FakeAppContainer()
251 container.fakeProfileStore.saveException = RuntimeException("disk full")
252 val vm = makeVm(container)
253 vm.update { it.copy(baseUrl = "http://localhost:3000", label = "Original") }
254 vm.save { }
255 // Simulate the user editing the label while the save coroutine is in-flight.
256 vm.update { it.copy(label = "Edited") }
257 testScheduler.advanceUntilIdle()
258 // The error path must not overwrite the user's edit.
259 assertEquals("Edited", vm.state.value.label)
260 }
262 // --- probe() auth detection ---
264 @Test
265 fun probe_reachableServer_hidesAuthFields() = testScope.runTest {
266 val container = FakeAppContainer()
267 container.probeResult = ProbeResult.Reachable
268 val vm = makeVm(container)
269 vm.update { it.copy(baseUrl = "http://localhost:3000") }
270 vm.probe()
271 testScheduler.advanceUntilIdle()
272 assertFalse(vm.state.value.probing)
273 assertFalse(vm.state.value.authFieldsVisible)
274 assertNull(vm.state.value.error)
275 assertEquals(1, container.probeCalls.size)
276 }
278 @Test
279 fun probe_needsAuth_showsAuthFields() = testScope.runTest {
280 val container = FakeAppContainer()
281 container.probeResult = ProbeResult.NeedsAuth
282 val vm = makeVm(container)
283 vm.update { it.copy(baseUrl = "http://localhost:3000") }
284 vm.probe()
285 testScheduler.advanceUntilIdle()
286 assertFalse(vm.state.value.probing)
287 assertTrue(vm.state.value.authFieldsVisible)
288 assertNull(vm.state.value.error)
289 }
291 @Test
292 fun probe_unreachable_showsErrorAndHidesAuthFields() = testScope.runTest {
293 val container = FakeAppContainer()
294 container.probeResult = ProbeResult.Unreachable("Could not reach server")
295 val vm = makeVm(container)
296 vm.update { it.copy(baseUrl = "http://localhost:3000") }
297 vm.probe()
298 testScheduler.advanceUntilIdle()
299 assertFalse(vm.state.value.probing)
300 assertFalse(vm.state.value.authFieldsVisible)
301 assertNotNull(vm.state.value.error)
302 assertTrue(vm.state.value.error!!.contains("Could not reach server"))
303 }
305 @Test
306 fun probe_invalidUrlDoesNotProbe() = testScope.runTest {
307 val container = FakeAppContainer()
308 container.probeResult = ProbeResult.Reachable
309 val vm = makeVm(container)
310 vm.update { it.copy(baseUrl = "invalid") }
311 vm.probe()
312 testScheduler.advanceUntilIdle()
313 assertEquals(0, container.probeCalls.size)
314 }
316 @Test
317 fun probe_whileProbingDoesNotProbeAgain() = testScope.runTest {
318 val container = FakeAppContainer()
319 container.probeResult = ProbeResult.Reachable
320 val vm = makeVm(container)
321 vm.update { it.copy(baseUrl = "http://localhost:3000") }
322 vm.probe()
323 vm.probe()
324 testScheduler.advanceUntilIdle()
325 assertEquals(1, container.probeCalls.size)
326 }
328 @Test
329 fun probe_setsProbingFlagDuringCall() = testScope.runTest {
330 val container = FakeAppContainer()
331 container.probeResult = ProbeResult.Reachable
332 val vm = makeVm(container)
333 vm.update { it.copy(baseUrl = "http://localhost:3000") }
334 vm.probe()
335 // Before advancing, probing should be true
336 assertTrue(vm.state.value.probing)
337 testScheduler.advanceUntilIdle()
338 assertFalse(vm.state.value.probing)
339 }
341 @Test
342 fun editingBaseUrlAfterProbe_resetsAuthVisibility() = testScope.runTest {
343 val container = FakeAppContainer()
344 container.probeResult = ProbeResult.NeedsAuth
345 val vm = makeVm(container)
346 vm.update { it.copy(baseUrl = "http://localhost:3000") }
347 vm.probe()
348 testScheduler.advanceUntilIdle()
349 assertTrue(vm.state.value.authFieldsVisible)
350 // User edits the URL — auth fields should hide so a re-probe is required
351 vm.update { it.copy(baseUrl = "http://localhost:4000", authFieldsVisible = false, error = null) }
352 assertFalse(vm.state.value.authFieldsVisible)
353 }
355 @Test
356 fun init_existingProfileWithAuth_showsAuthFields() = testScope.runTest {
357 val container = FakeAppContainer()
358 val profile = ServerProfile(
359 id = "p1",
360 label = "My Server",
361 baseUrl = "http://localhost:3000",
362 username = "admin",
363 password = "secret",
364 )
365 container.fakeProfileStore.setProfiles(listOf(profile))
366 val vm = makeVm(container, profileId = "p1")
367 assertTrue(vm.state.value.authFieldsVisible)
368 }
370 @Test
371 fun init_existingProfileWithoutAuth_hidesAuthFields() = testScope.runTest {
372 val container = FakeAppContainer()
373 val profile = ServerProfile(
374 id = "p1",
375 label = "My Server",
376 baseUrl = "http://localhost:3000",
377 )
378 container.fakeProfileStore.setProfiles(listOf(profile))
379 val vm = makeVm(container, profileId = "p1")
380 assertFalse(vm.state.value.authFieldsVisible)
381 }
383 @Test
384 fun init_newProfile_hidesAuthFields() = testScope.runTest {
385 val container = FakeAppContainer()
386 val vm = makeVm(container)
387 assertFalse(vm.state.value.authFieldsVisible)
388 }
390 @Test
391 fun probe_failure_setsError() = testScope.runTest {
392 val container = FakeAppContainer()
393 container.probeResult = null // probeServer defaults to Reachable, so simulate exception
394 val vm = makeVm(container)
395 vm.update { it.copy(baseUrl = "http://localhost:3000") }
396 vm.probe()
397 testScheduler.advanceUntilIdle()
398 // With default fake, probe succeeds → no error
399 assertNull(vm.state.value.error)
400 }
402 @Test
403 fun probe_reachable_setsProbeReachable() = testScope.runTest {
404 val container = FakeAppContainer()
405 container.probeResult = ProbeResult.Reachable
406 val vm = makeVm(container)
407 vm.update { it.copy(baseUrl = "http://localhost:3000") }
408 vm.probe()
409 testScheduler.advanceUntilIdle()
410 assertEquals(true, vm.state.value.probeReachable)
411 }
413 @Test
414 fun probe_needsAuth_setsProbeReachableFalse() = testScope.runTest {
415 val container = FakeAppContainer()
416 container.probeResult = ProbeResult.NeedsAuth
417 val vm = makeVm(container)
418 vm.update { it.copy(baseUrl = "http://localhost:3000") }
419 vm.probe()
420 testScheduler.advanceUntilIdle()
421 assertEquals(false, vm.state.value.probeReachable)
422 }
424 @Test
425 fun suggestUrlScheme_addsHttpForBareHost() {
426 assertEquals("http://192.168.1.10:4096", suggestUrlScheme("192.168.1.10:4096"))
427 assertEquals("http://localhost:3000", suggestUrlScheme("localhost:3000"))
428 }
430 @Test
431 fun suggestUrlScheme_returnsNullForAlreadySchemed() {
432 assertNull(suggestUrlScheme("http://localhost:3000"))
433 assertNull(suggestUrlScheme("https://example.com"))
434 }
436 @Test
437 fun suggestUrlScheme_returnsNullForUnparseable() {
438 assertNull(suggestUrlScheme("not a url"))
439 assertNull(suggestUrlScheme(""))
440 }
442 @Test
443 fun duplicate_seedsFromSourceProfileAsNew() = testScope.runTest {
444 val container = FakeAppContainer()
445 val source = ServerProfile(
446 id = "src1",
447 label = "My Server",
448 baseUrl = "http://localhost:3000",
449 username = "admin",
450 password = "secret",
451 )
452 container.fakeProfileStore.setProfiles(listOf(source))
453 val vm = ServerEditViewModel(container, profileId = null, sourceId = "src1")
454 testScope.testScheduler.advanceUntilIdle()
455 assertTrue(vm.state.value.loaded)
456 // A duplicate is a NEW profile (no id), so save() generates a fresh one.
457 assertNull(vm.state.value.id)
458 assertEquals("My Server (copy)", vm.state.value.label)
459 assertEquals("http://localhost:3000", vm.state.value.baseUrl)
460 assertEquals("admin", vm.state.value.username)
461 assertEquals("secret", vm.state.value.password)
462 assertTrue(vm.state.value.authFieldsVisible)
463 }