git.iko.soy/opencode-android
on d647ff2 help sign in
opencode-android/app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt
460 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 // --- connect(): probe folded into the primary action ---
264 @Test
265 fun connect_reachableServer_savesAndConnects() = 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 var doneCalled = false
271 vm.connect { doneCalled = true }
272 testScheduler.advanceUntilIdle()
273 assertFalse(vm.state.value.authFieldsVisible)
274 assertNull(vm.state.value.error)
275 assertEquals(1, container.probeCalls.size)
276 // Reachable → the profile is saved and a connection is opened.
277 assertNotNull(container.fakeProfileStore.savedProfile)
278 assertEquals(1, container.connectCalls.size)
279 assertTrue(doneCalled)
280 }
282 @Test
283 fun connect_needsAuth_showsAuthFieldsWithoutSaving() = testScope.runTest {
284 val container = FakeAppContainer()
285 container.probeResult = ProbeResult.NeedsAuth
286 val vm = makeVm(container)
287 vm.update { it.copy(baseUrl = "http://localhost:3000") }
288 var doneCalled = false
289 vm.connect { doneCalled = true }
290 testScheduler.advanceUntilIdle()
291 assertFalse(vm.state.value.saving)
292 assertTrue(vm.state.value.authFieldsVisible)
293 assertNull(vm.state.value.error)
294 // Auth needed: reveal the fields; nothing is saved or connected yet.
295 assertNull(container.fakeProfileStore.savedProfile)
296 assertEquals(0, container.connectCalls.size)
297 assertFalse(doneCalled)
298 }
300 @Test
301 fun connect_unreachable_showsErrorAndDoesNotSave() = testScope.runTest {
302 val container = FakeAppContainer()
303 container.probeResult = ProbeResult.Unreachable("Could not reach server")
304 val vm = makeVm(container)
305 vm.update { it.copy(baseUrl = "http://localhost:3000") }
306 vm.connect { }
307 testScheduler.advanceUntilIdle()
308 assertFalse(vm.state.value.saving)
309 assertFalse(vm.state.value.authFieldsVisible)
310 assertNotNull(vm.state.value.error)
311 assertTrue(vm.state.value.error!!.contains("Could not reach server"))
312 assertNull(container.fakeProfileStore.savedProfile)
313 }
315 @Test
316 fun connect_withCredentials_skipsProbeAndSaves() = testScope.runTest {
317 val container = FakeAppContainer()
318 container.probeResult = ProbeResult.NeedsAuth
319 val vm = makeVm(container)
320 vm.update { it.copy(baseUrl = "http://localhost:3000", username = "admin", password = "secret") }
321 var doneCalled = false
322 vm.connect { doneCalled = true }
323 testScheduler.advanceUntilIdle()
324 // Credentials present: no probe, straight to save + connect.
325 assertEquals(0, container.probeCalls.size)
326 assertNotNull(container.fakeProfileStore.savedProfile)
327 assertEquals(1, container.connectCalls.size)
328 assertTrue(doneCalled)
329 }
331 @Test
332 fun connect_authFieldsVisible_skipsProbe() = testScope.runTest {
333 val container = FakeAppContainer()
334 container.probeResult = ProbeResult.NeedsAuth
335 val vm = makeVm(container)
336 // Simulate a first Connect that revealed auth, then a second Connect with creds typed.
337 vm.update { it.copy(baseUrl = "http://localhost:3000", authFieldsVisible = true) }
338 vm.connect { }
339 testScheduler.advanceUntilIdle()
340 assertEquals(0, container.probeCalls.size)
341 assertNotNull(container.fakeProfileStore.savedProfile)
342 }
344 @Test
345 fun connect_invalidUrlDoesNothing() = testScope.runTest {
346 val container = FakeAppContainer()
347 container.probeResult = ProbeResult.Reachable
348 val vm = makeVm(container)
349 vm.update { it.copy(baseUrl = "invalid") }
350 var doneCalled = false
351 vm.connect { doneCalled = true }
352 testScheduler.advanceUntilIdle()
353 assertEquals(0, container.probeCalls.size)
354 assertNull(container.fakeProfileStore.savedProfile)
355 assertFalse(doneCalled)
356 }
358 @Test
359 fun connect_whileSavingDoesNotConnectAgain() = testScope.runTest {
360 val container = FakeAppContainer()
361 container.probeResult = ProbeResult.Reachable
362 val vm = makeVm(container)
363 vm.update { it.copy(baseUrl = "http://localhost:3000") }
364 vm.connect { }
365 vm.connect { }
366 testScheduler.advanceUntilIdle()
367 assertEquals(1, container.probeCalls.size)
368 }
370 @Test
371 fun connect_setsSavingFlagDuringProbeThenClearsOnAuth() = testScope.runTest {
372 val container = FakeAppContainer()
373 // NeedsAuth ends the flow (reveals auth) rather than navigating away, so saving
374 // returns to false and is observable — the reachable path stays saving until onDone.
375 container.probeResult = ProbeResult.NeedsAuth
376 val vm = makeVm(container)
377 vm.update { it.copy(baseUrl = "http://localhost:3000") }
378 vm.connect { }
379 // Before advancing, the probe is in flight → saving is true.
380 assertTrue(vm.state.value.saving)
381 testScheduler.advanceUntilIdle()
382 assertFalse(vm.state.value.saving)
383 }
385 @Test
386 fun init_existingProfileWithAuth_showsAuthFields() = testScope.runTest {
387 val container = FakeAppContainer()
388 val profile = ServerProfile(
389 id = "p1",
390 label = "My Server",
391 baseUrl = "http://localhost:3000",
392 username = "admin",
393 password = "secret",
394 )
395 container.fakeProfileStore.setProfiles(listOf(profile))
396 val vm = makeVm(container, profileId = "p1")
397 assertTrue(vm.state.value.authFieldsVisible)
398 }
400 @Test
401 fun init_existingProfileWithoutAuth_hidesAuthFields() = testScope.runTest {
402 val container = FakeAppContainer()
403 val profile = ServerProfile(
404 id = "p1",
405 label = "My Server",
406 baseUrl = "http://localhost:3000",
407 )
408 container.fakeProfileStore.setProfiles(listOf(profile))
409 val vm = makeVm(container, profileId = "p1")
410 assertFalse(vm.state.value.authFieldsVisible)
411 }
413 @Test
414 fun init_newProfile_hidesAuthFields() = testScope.runTest {
415 val container = FakeAppContainer()
416 val vm = makeVm(container)
417 assertFalse(vm.state.value.authFieldsVisible)
418 }
420 @Test
421 fun suggestUrlScheme_addsHttpForBareHost() {
422 assertEquals("http://192.168.1.10:4096", suggestUrlScheme("192.168.1.10:4096"))
423 assertEquals("http://localhost:3000", suggestUrlScheme("localhost:3000"))
424 }
426 @Test
427 fun suggestUrlScheme_returnsNullForAlreadySchemed() {
428 assertNull(suggestUrlScheme("http://localhost:3000"))
429 assertNull(suggestUrlScheme("https://example.com"))
430 }
432 @Test
433 fun suggestUrlScheme_returnsNullForUnparseable() {
434 assertNull(suggestUrlScheme("not a url"))
435 assertNull(suggestUrlScheme(""))
436 }
438 @Test
439 fun duplicate_seedsFromSourceProfileAsNew() = testScope.runTest {
440 val container = FakeAppContainer()
441 val source = ServerProfile(
442 id = "src1",
443 label = "My Server",
444 baseUrl = "http://localhost:3000",
445 username = "admin",
446 password = "secret",
447 )
448 container.fakeProfileStore.setProfiles(listOf(source))
449 val vm = ServerEditViewModel(container, profileId = null, sourceId = "src1")
450 testScope.testScheduler.advanceUntilIdle()
451 assertTrue(vm.state.value.loaded)
452 // A duplicate is a NEW profile (no id), so save() generates a fresh one.
453 assertNull(vm.state.value.id)
454 assertEquals("My Server (copy)", vm.state.value.label)
455 assertEquals("http://localhost:3000", vm.state.value.baseUrl)
456 assertEquals("admin", vm.state.value.username)
457 assertEquals("secret", vm.state.value.password)
458 assertTrue(vm.state.value.authFieldsVisible)
459 }