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)
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)
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)
171 fun save_whileSavingDoesNotSaveAgain() = testScope.runTest {
172 val container = FakeAppContainer()
173 val vm = makeVm(container)
174 vm.update { it.copy(baseUrl = "http://localhost:3000") }
176 vm.save { doneCount++ }
177 // While saving, try to save again
178 vm.save { doneCount++ }
179 testScheduler.advanceUntilIdle()
180 assertEquals(1, doneCount)
184 fun save_generatesIdForNewProfile() = testScope.runTest {
185 val container = FakeAppContainer()
186 val vm = makeVm(container)
187 vm.update { it.copy(baseUrl = "http://localhost:3000") }
189 testScheduler.advanceUntilIdle()
190 val saved = container.fakeProfileStore.savedProfile
191 assertNotNull(saved?.id)
192 assertTrue(saved!!.id.isNotEmpty())
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") }
203 testScheduler.advanceUntilIdle()
204 assertEquals("existing-id", container.fakeProfileStore.savedProfile?.id)
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 ") }
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)
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 = "") }
226 testScheduler.advanceUntilIdle()
227 val saved = container.fakeProfileStore.savedProfile!!
228 assertNull(saved.username)
229 assertNull(saved.password)
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"))
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") }
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)
262 // --- connect(): probe folded into the primary action ---
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)
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)
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") }
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)
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)
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) }
339 testScheduler.advanceUntilIdle()
340 assertEquals(0, container.probeCalls.size)
341 assertNotNull(container.fakeProfileStore.savedProfile)
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)
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") }
366 testScheduler.advanceUntilIdle()
367 assertEquals(1, container.probeCalls.size)
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") }
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)
386 fun init_existingProfileWithAuth_showsAuthFields() = testScope.runTest {
387 val container = FakeAppContainer()
388 val profile = ServerProfile(
391 baseUrl = "http://localhost:3000",
395 container.fakeProfileStore.setProfiles(listOf(profile))
396 val vm = makeVm(container, profileId = "p1")
397 assertTrue(vm.state.value.authFieldsVisible)
401 fun init_existingProfileWithoutAuth_hidesAuthFields() = testScope.runTest {
402 val container = FakeAppContainer()
403 val profile = ServerProfile(
406 baseUrl = "http://localhost:3000",
408 container.fakeProfileStore.setProfiles(listOf(profile))
409 val vm = makeVm(container, profileId = "p1")
410 assertFalse(vm.state.value.authFieldsVisible)
414 fun init_newProfile_hidesAuthFields() = testScope.runTest {
415 val container = FakeAppContainer()
416 val vm = makeVm(container)
417 assertFalse(vm.state.value.authFieldsVisible)
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"))
427 fun suggestUrlScheme_returnsNullForAlreadySchemed() {
428 assertNull(suggestUrlScheme("http://localhost:3000"))
429 assertNull(suggestUrlScheme("https://example.com"))
433 fun suggestUrlScheme_returnsNullForUnparseable() {
434 assertNull(suggestUrlScheme("not a url"))
435 assertNull(suggestUrlScheme(""))
439 fun duplicate_seedsFromSourceProfileAsNew() = testScope.runTest {
440 val container = FakeAppContainer()
441 val source = ServerProfile(
444 baseUrl = "http://localhost:3000",
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)
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