git.iko.soy/opencode-android
on f98707c help sign in
opencode-android/app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt
506 lines 20 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 @Test
143 fun canSave_trueForBareHostWithoutScheme() = testScope.runTest {
144 val container = FakeAppContainer()
145 val vm = makeVm(container)
146 // A bare host:port is schemed by normalizeForSave at save time, so canSave must
147 // accept it — the user shouldn't have to type a protocol to enable Save/Connect.
148 vm.update { it.copy(baseUrl = "localhost:3000") }
149 assertTrue(vm.state.value.canSave)
150 vm.update { it.copy(baseUrl = "192.168.1.10:4096") }
151 assertTrue(vm.state.value.canSave)
152 }
154 @Test
155 fun canSave_trueForBarePublicDomain() = testScope.runTest {
156 val container = FakeAppContainer()
157 val vm = makeVm(container)
158 // A public-looking domain with no port is schemed to https:// by normalizeForSave,
159 // which OkHttp accepts, so canSave must be true.
160 vm.update { it.copy(baseUrl = "example.com") }
161 assertTrue(vm.state.value.canSave)
162 }
164 // --- save() ---
166 @Test
167 fun save_persistsProfileAndCallsOnDone() = testScope.runTest {
168 val container = FakeAppContainer()
169 val vm = makeVm(container)
170 vm.update { it.copy(baseUrl = "http://localhost:3000", label = "Test") }
171 var doneCalled = false
172 vm.save { doneCalled = true }
173 testScheduler.advanceUntilIdle()
174 assertTrue(doneCalled)
175 assertNotNull(container.fakeProfileStore.savedProfile)
176 assertEquals("http://localhost:3000", container.fakeProfileStore.savedProfile?.baseUrl)
177 assertEquals("Test", container.fakeProfileStore.savedProfile?.label)
178 }
180 @Test
181 fun save_invalidUrlDoesNotSave() = testScope.runTest {
182 val container = FakeAppContainer()
183 val vm = makeVm(container)
184 vm.update { it.copy(baseUrl = "not a url") }
185 var doneCalled = false
186 vm.save { doneCalled = true }
187 testScheduler.advanceUntilIdle()
188 assertFalse(doneCalled)
189 assertNull(container.fakeProfileStore.savedProfile)
190 }
192 @Test
193 fun save_whileSavingDoesNotSaveAgain() = testScope.runTest {
194 val container = FakeAppContainer()
195 val vm = makeVm(container)
196 vm.update { it.copy(baseUrl = "http://localhost:3000") }
197 var doneCount = 0
198 vm.save { doneCount++ }
199 // While saving, try to save again
200 vm.save { doneCount++ }
201 testScheduler.advanceUntilIdle()
202 assertEquals(1, doneCount)
203 }
205 @Test
206 fun save_generatesIdForNewProfile() = testScope.runTest {
207 val container = FakeAppContainer()
208 val vm = makeVm(container)
209 vm.update { it.copy(baseUrl = "http://localhost:3000") }
210 vm.save { }
211 testScheduler.advanceUntilIdle()
212 val saved = container.fakeProfileStore.savedProfile
213 assertNotNull(saved?.id)
214 assertTrue(saved!!.id.isNotEmpty())
215 }
217 @Test
218 fun save_preservesIdForExistingProfile() = testScope.runTest {
219 val container = FakeAppContainer()
220 val profile = ServerProfile(id = "existing-id", label = "Old", baseUrl = "http://old")
221 container.fakeProfileStore.setProfiles(listOf(profile))
222 val vm = makeVm(container, profileId = "existing-id")
223 vm.update { it.copy(baseUrl = "http://new") }
224 vm.save { }
225 testScheduler.advanceUntilIdle()
226 assertEquals("existing-id", container.fakeProfileStore.savedProfile?.id)
227 }
229 @Test
230 fun save_trimsFields() = testScope.runTest {
231 val container = FakeAppContainer()
232 val vm = makeVm(container)
233 vm.update { it.copy(baseUrl = " http://localhost:3000 ", label = " Test ", username = " admin ") }
234 vm.save { }
235 testScheduler.advanceUntilIdle()
236 val saved = container.fakeProfileStore.savedProfile!!
237 assertEquals("http://localhost:3000", saved.baseUrl)
238 assertEquals("Test", saved.label)
239 assertEquals("admin", saved.username)
240 }
242 @Test
243 fun save_appliesHttpsSchemeToBareLanHost() = testScope.runTest {
244 val container = FakeAppContainer()
245 val vm = makeVm(container)
246 vm.update { it.copy(baseUrl = "localhost:3000", label = "LAN") }
247 vm.save { }
248 testScheduler.advanceUntilIdle()
249 val saved = container.fakeProfileStore.savedProfile!!
250 // A bare host is always schemed to https:// (the safe default). The user can still
251 // opt into cleartext by typing http:// explicitly.
252 assertEquals("https://localhost:3000", saved.baseUrl)
253 assertEquals("LAN", saved.label)
254 // The editor's own field is refreshed to the schemed form so a just-saved bare-host
255 // entry doesn't read as dirty against its own stored value on re-open.
256 assertEquals("https://localhost:3000", vm.state.value.baseUrl)
257 assertFalse(vm.state.value.isDirty)
258 }
260 @Test
261 fun save_appliesHttpsSchemeToBarePublicDomain() = testScope.runTest {
262 val container = FakeAppContainer()
263 val vm = makeVm(container)
264 vm.update { it.copy(baseUrl = "example.com") }
265 vm.save { }
266 testScheduler.advanceUntilIdle()
267 val saved = container.fakeProfileStore.savedProfile!!
268 assertEquals("https://example.com", saved.baseUrl)
269 }
271 @Test
272 fun save_preservesExplicitHttpSchemeForPublicDomain() = testScope.runTest {
273 val container = FakeAppContainer()
274 val vm = makeVm(container)
275 // The user typed the scheme explicitly — don't upgrade a public domain to https://
276 // behind their back; preserve their explicit http:// choice.
277 vm.update { it.copy(baseUrl = "http://example.com") }
278 vm.save { }
279 testScheduler.advanceUntilIdle()
280 val saved = container.fakeProfileStore.savedProfile!!
281 assertEquals("http://example.com", saved.baseUrl)
282 }
284 @Test
285 fun save_emptyUsernameClearsCredentials() = testScope.runTest {
286 val container = FakeAppContainer()
287 val vm = makeVm(container)
288 vm.update { it.copy(baseUrl = "http://localhost:3000", username = "", password = "") }
289 vm.save { }
290 testScheduler.advanceUntilIdle()
291 val saved = container.fakeProfileStore.savedProfile!!
292 assertNull(saved.username)
293 assertNull(saved.password)
294 }
296 @Test
297 fun save_failureSetsErrorAndClearsSaving() = testScope.runTest {
298 val container = FakeAppContainer()
299 container.fakeProfileStore.saveException = RuntimeException("disk full")
300 val vm = makeVm(container)
301 vm.update { it.copy(baseUrl = "http://localhost:3000") }
302 var doneCalled = false
303 vm.save { doneCalled = true }
304 testScheduler.advanceUntilIdle()
305 assertFalse(doneCalled)
306 assertFalse(vm.state.value.saving)
307 // The error must come from the exception, not from a clobbered ServerEditState.
308 assertNotNull(vm.state.value.error)
309 assertTrue(vm.state.value.error!!.contains("disk full"))
310 }
312 @Test
313 fun save_failureDoesNotClobberUserEdits() = testScope.runTest {
314 val container = FakeAppContainer()
315 container.fakeProfileStore.saveException = RuntimeException("disk full")
316 val vm = makeVm(container)
317 vm.update { it.copy(baseUrl = "http://localhost:3000", label = "Original") }
318 vm.save { }
319 // Simulate the user editing the label while the save coroutine is in-flight.
320 vm.update { it.copy(label = "Edited") }
321 testScheduler.advanceUntilIdle()
322 // The error path must not overwrite the user's edit.
323 assertEquals("Edited", vm.state.value.label)
324 }
326 // --- connect(): probe folded into the primary action ---
328 @Test
329 fun connect_reachableServer_savesAndConnects() = 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 var doneCalled = false
335 vm.connect { doneCalled = true }
336 testScheduler.advanceUntilIdle()
337 assertFalse(vm.state.value.authFieldsVisible)
338 assertNull(vm.state.value.error)
339 assertEquals(1, container.probeCalls.size)
340 // Reachable → the profile is saved and a connection is opened.
341 assertNotNull(container.fakeProfileStore.savedProfile)
342 assertEquals(1, container.connectCalls.size)
343 assertTrue(doneCalled)
344 }
346 @Test
347 fun connect_needsAuth_showsAuthFieldsWithoutSaving() = testScope.runTest {
348 val container = FakeAppContainer()
349 container.probeResult = ProbeResult.NeedsAuth
350 val vm = makeVm(container)
351 vm.update { it.copy(baseUrl = "http://localhost:3000") }
352 var doneCalled = false
353 vm.connect { doneCalled = true }
354 testScheduler.advanceUntilIdle()
355 assertFalse(vm.state.value.saving)
356 assertTrue(vm.state.value.authFieldsVisible)
357 assertNull(vm.state.value.error)
358 // Auth needed: reveal the fields; nothing is saved or connected yet.
359 assertNull(container.fakeProfileStore.savedProfile)
360 assertEquals(0, container.connectCalls.size)
361 assertFalse(doneCalled)
362 }
364 @Test
365 fun connect_unreachable_showsErrorAndDoesNotSave() = testScope.runTest {
366 val container = FakeAppContainer()
367 container.probeResult = ProbeResult.Unreachable("Could not reach server")
368 val vm = makeVm(container)
369 vm.update { it.copy(baseUrl = "http://localhost:3000") }
370 vm.connect { }
371 testScheduler.advanceUntilIdle()
372 assertFalse(vm.state.value.saving)
373 assertFalse(vm.state.value.authFieldsVisible)
374 assertNotNull(vm.state.value.error)
375 assertTrue(vm.state.value.error!!.contains("Could not reach server"))
376 assertNull(container.fakeProfileStore.savedProfile)
377 }
379 @Test
380 fun connect_withCredentials_skipsProbeAndSaves() = testScope.runTest {
381 val container = FakeAppContainer()
382 container.probeResult = ProbeResult.NeedsAuth
383 val vm = makeVm(container)
384 vm.update { it.copy(baseUrl = "http://localhost:3000", username = "admin", password = "secret") }
385 var doneCalled = false
386 vm.connect { doneCalled = true }
387 testScheduler.advanceUntilIdle()
388 // Credentials present: no probe, straight to save + connect.
389 assertEquals(0, container.probeCalls.size)
390 assertNotNull(container.fakeProfileStore.savedProfile)
391 assertEquals(1, container.connectCalls.size)
392 assertTrue(doneCalled)
393 }
395 @Test
396 fun connect_authFieldsVisible_skipsProbe() = testScope.runTest {
397 val container = FakeAppContainer()
398 container.probeResult = ProbeResult.NeedsAuth
399 val vm = makeVm(container)
400 // Simulate a first Connect that revealed auth, then a second Connect with creds typed.
401 vm.update { it.copy(baseUrl = "http://localhost:3000", authFieldsVisible = true) }
402 vm.connect { }
403 testScheduler.advanceUntilIdle()
404 assertEquals(0, container.probeCalls.size)
405 assertNotNull(container.fakeProfileStore.savedProfile)
406 }
408 @Test
409 fun connect_invalidUrlDoesNothing() = testScope.runTest {
410 val container = FakeAppContainer()
411 container.probeResult = ProbeResult.Reachable
412 val vm = makeVm(container)
413 vm.update { it.copy(baseUrl = "not a url") }
414 var doneCalled = false
415 vm.connect { doneCalled = true }
416 testScheduler.advanceUntilIdle()
417 assertEquals(0, container.probeCalls.size)
418 assertNull(container.fakeProfileStore.savedProfile)
419 assertFalse(doneCalled)
420 }
422 @Test
423 fun connect_whileSavingDoesNotConnectAgain() = testScope.runTest {
424 val container = FakeAppContainer()
425 container.probeResult = ProbeResult.Reachable
426 val vm = makeVm(container)
427 vm.update { it.copy(baseUrl = "http://localhost:3000") }
428 vm.connect { }
429 vm.connect { }
430 testScheduler.advanceUntilIdle()
431 assertEquals(1, container.probeCalls.size)
432 }
434 @Test
435 fun connect_setsSavingFlagDuringProbeThenClearsOnAuth() = testScope.runTest {
436 val container = FakeAppContainer()
437 // NeedsAuth ends the flow (reveals auth) rather than navigating away, so saving
438 // returns to false and is observable — the reachable path stays saving until onDone.
439 container.probeResult = ProbeResult.NeedsAuth
440 val vm = makeVm(container)
441 vm.update { it.copy(baseUrl = "http://localhost:3000") }
442 vm.connect { }
443 // Before advancing, the probe is in flight → saving is true.
444 assertTrue(vm.state.value.saving)
445 testScheduler.advanceUntilIdle()
446 assertFalse(vm.state.value.saving)
447 }
449 @Test
450 fun init_existingProfileWithAuth_showsAuthFields() = testScope.runTest {
451 val container = FakeAppContainer()
452 val profile = ServerProfile(
453 id = "p1",
454 label = "My Server",
455 baseUrl = "http://localhost:3000",
456 username = "admin",
457 password = "secret",
458 )
459 container.fakeProfileStore.setProfiles(listOf(profile))
460 val vm = makeVm(container, profileId = "p1")
461 assertTrue(vm.state.value.authFieldsVisible)
462 }
464 @Test
465 fun init_existingProfileWithoutAuth_hidesAuthFields() = testScope.runTest {
466 val container = FakeAppContainer()
467 val profile = ServerProfile(
468 id = "p1",
469 label = "My Server",
470 baseUrl = "http://localhost:3000",
471 )
472 container.fakeProfileStore.setProfiles(listOf(profile))
473 val vm = makeVm(container, profileId = "p1")
474 assertFalse(vm.state.value.authFieldsVisible)
475 }
477 @Test
478 fun init_newProfile_hidesAuthFields() = testScope.runTest {
479 val container = FakeAppContainer()
480 val vm = makeVm(container)
481 assertFalse(vm.state.value.authFieldsVisible)
482 }
484 @Test
485 fun duplicate_seedsFromSourceProfileAsNew() = testScope.runTest {
486 val container = FakeAppContainer()
487 val source = ServerProfile(
488 id = "src1",
489 label = "My Server",
490 baseUrl = "http://localhost:3000",
491 username = "admin",
492 password = "secret",
493 )
494 container.fakeProfileStore.setProfiles(listOf(source))
495 val vm = ServerEditViewModel(container, profileId = null, sourceId = "src1")
496 testScope.testScheduler.advanceUntilIdle()
497 assertTrue(vm.state.value.loaded)
498 // A duplicate is a NEW profile (no id), so save() generates a fresh one.
499 assertNull(vm.state.value.id)
500 assertEquals("My Server (copy)", vm.state.value.label)
501 assertEquals("http://localhost:3000", vm.state.value.baseUrl)
502 assertEquals("admin", vm.state.value.username)
503 assertEquals("secret", vm.state.value.password)
504 assertTrue(vm.state.value.authFieldsVisible)
505 }