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
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
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)
30 Dispatchers.setMain(testDispatcher)
35 Dispatchers.resetMain()
38 private fun makeVm(container: FakeAppContainer, profileId: String? = null): ServerEditViewModel {
39 val vm = ServerEditViewModel(container, profileId)
40 testScope.testScheduler.advanceUntilIdle()
44 // --- new profile ---
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)
58 // --- existing profile ---
61 fun init_existingProfile_loadsFormFields() = testScope.runTest {
62 val container = FakeAppContainer()
63 val profile = ServerProfile(
66 baseUrl = "http://localhost:3000",
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)
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)
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)
100 // --- canSave validation ---
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)
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)
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)
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)
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)
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)
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)
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)
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)
193 fun save_whileSavingDoesNotSaveAgain() = testScope.runTest {
194 val container = FakeAppContainer()
195 val vm = makeVm(container)
196 vm.update { it.copy(baseUrl = "http://localhost:3000") }
198 vm.save { doneCount++ }
199 // While saving, try to save again
200 vm.save { doneCount++ }
201 testScheduler.advanceUntilIdle()
202 assertEquals(1, doneCount)
206 fun save_generatesIdForNewProfile() = testScope.runTest {
207 val container = FakeAppContainer()
208 val vm = makeVm(container)
209 vm.update { it.copy(baseUrl = "http://localhost:3000") }
211 testScheduler.advanceUntilIdle()
212 val saved = container.fakeProfileStore.savedProfile
213 assertNotNull(saved?.id)
214 assertTrue(saved!!.id.isNotEmpty())
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") }
225 testScheduler.advanceUntilIdle()
226 assertEquals("existing-id", container.fakeProfileStore.savedProfile?.id)
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 ") }
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)
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") }
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)
261 fun save_appliesHttpsSchemeToBarePublicDomain() = testScope.runTest {
262 val container = FakeAppContainer()
263 val vm = makeVm(container)
264 vm.update { it.copy(baseUrl = "example.com") }
266 testScheduler.advanceUntilIdle()
267 val saved = container.fakeProfileStore.savedProfile!!
268 assertEquals("https://example.com", saved.baseUrl)
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") }
279 testScheduler.advanceUntilIdle()
280 val saved = container.fakeProfileStore.savedProfile!!
281 assertEquals("http://example.com", saved.baseUrl)
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 = "") }
290 testScheduler.advanceUntilIdle()
291 val saved = container.fakeProfileStore.savedProfile!!
292 assertNull(saved.username)
293 assertNull(saved.password)
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"))
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") }
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)
326 // --- connect(): probe folded into the primary action ---
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)
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)
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") }
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)
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)
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) }
403 testScheduler.advanceUntilIdle()
404 assertEquals(0, container.probeCalls.size)
405 assertNotNull(container.fakeProfileStore.savedProfile)
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)
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") }
430 testScheduler.advanceUntilIdle()
431 assertEquals(1, container.probeCalls.size)
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") }
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)
450 fun init_existingProfileWithAuth_showsAuthFields() = testScope.runTest {
451 val container = FakeAppContainer()
452 val profile = ServerProfile(
455 baseUrl = "http://localhost:3000",
459 container.fakeProfileStore.setProfiles(listOf(profile))
460 val vm = makeVm(container, profileId = "p1")
461 assertTrue(vm.state.value.authFieldsVisible)
465 fun init_existingProfileWithoutAuth_hidesAuthFields() = testScope.runTest {
466 val container = FakeAppContainer()
467 val profile = ServerProfile(
470 baseUrl = "http://localhost:3000",
472 container.fakeProfileStore.setProfiles(listOf(profile))
473 val vm = makeVm(container, profileId = "p1")
474 assertFalse(vm.state.value.authFieldsVisible)
478 fun init_newProfile_hidesAuthFields() = testScope.runTest {
479 val container = FakeAppContainer()
480 val vm = makeVm(container)
481 assertFalse(vm.state.value.authFieldsVisible)
485 fun duplicate_seedsFromSourceProfileAsNew() = testScope.runTest {
486 val container = FakeAppContainer()
487 val source = ServerProfile(
490 baseUrl = "http://localhost:3000",
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)
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