git.iko.soy/opencode-android
on 1181007 help sign in
opencode-android/app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt
425 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): ServerEditViewModel {
39 val vm = ServerEditViewModel(container)
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)
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 // --- update() ---
60 @Test
61 fun update_modifiesState() = testScope.runTest {
62 val container = FakeAppContainer()
63 val vm = makeVm(container)
64 vm.update { it.copy(label = "New Label") }
65 assertEquals("New Label", vm.state.value.label)
66 }
68 // --- canSave validation ---
70 @Test
71 fun canSave_falseForBlankBaseUrl() = testScope.runTest {
72 val container = FakeAppContainer()
73 val vm = makeVm(container)
74 vm.update { it.copy(baseUrl = "") }
75 assertFalse(vm.state.value.canSave)
76 }
78 @Test
79 fun canSave_falseForInvalidUrl() = testScope.runTest {
80 val container = FakeAppContainer()
81 val vm = makeVm(container)
82 vm.update { it.copy(baseUrl = "not a url") }
83 assertFalse(vm.state.value.canSave)
84 }
86 @Test
87 fun canSave_trueForValidHttpUrl() = testScope.runTest {
88 val container = FakeAppContainer()
89 val vm = makeVm(container)
90 vm.update { it.copy(baseUrl = "http://localhost:3000") }
91 assertTrue(vm.state.value.canSave)
92 }
94 @Test
95 fun canSave_trueForValidHttpsUrl() = testScope.runTest {
96 val container = FakeAppContainer()
97 val vm = makeVm(container)
98 vm.update { it.copy(baseUrl = "https://example.com") }
99 assertTrue(vm.state.value.canSave)
100 }
102 @Test
103 fun canSave_falseForFtpUrl() = testScope.runTest {
104 val container = FakeAppContainer()
105 val vm = makeVm(container)
106 vm.update { it.copy(baseUrl = "ftp://example.com") }
107 assertFalse(vm.state.value.canSave)
108 }
110 @Test
111 fun canSave_trueForBareHostWithoutScheme() = testScope.runTest {
112 val container = FakeAppContainer()
113 val vm = makeVm(container)
114 // A bare host:port is schemed by normalizeForSave at save time, so canSave must
115 // accept it — the user shouldn't have to type a protocol to enable Save/Connect.
116 vm.update { it.copy(baseUrl = "localhost:3000") }
117 assertTrue(vm.state.value.canSave)
118 vm.update { it.copy(baseUrl = "192.168.1.10:4096") }
119 assertTrue(vm.state.value.canSave)
120 }
122 @Test
123 fun canSave_trueForBarePublicDomain() = testScope.runTest {
124 val container = FakeAppContainer()
125 val vm = makeVm(container)
126 // A public-looking domain with no port is schemed to https:// by normalizeForSave,
127 // which OkHttp accepts, so canSave must be true.
128 vm.update { it.copy(baseUrl = "example.com") }
129 assertTrue(vm.state.value.canSave)
130 }
132 // --- hostFromBaseUrl (default label derivation) ---
134 @Test
135 fun hostFromBaseUrl_extractsHostAndPort() {
136 assertEquals("localhost:3000", hostFromBaseUrl("http://localhost:3000"))
137 assertEquals("192.168.1.10:4096", hostFromBaseUrl("192.168.1.10:4096"))
138 assertEquals("example.com", hostFromBaseUrl("https://example.com"))
139 assertEquals("example.com", hostFromBaseUrl("example.com"))
140 // Default port (443 for https) is omitted.
141 assertEquals("example.com", hostFromBaseUrl("https://example.com:443"))
142 }
144 // --- connect() save path (probe Reachable → save + connect + ping) ---
145 // The add-server form has no label field, so the saved label is derived from the hostname
146 // via hostFromBaseUrl. Connect is the single primary action and its probe IS the
147 // connection test.
149 @Test
150 fun connect_persistsProfileAndCallsOnDone() = testScope.runTest {
151 val container = FakeAppContainer()
152 container.probeResult = ProbeResult.Reachable
153 val vm = makeVm(container)
154 vm.update { it.copy(baseUrl = "http://localhost:3000") }
155 var doneCalled = false
156 vm.connect { doneCalled = true }
157 testScheduler.advanceUntilIdle()
158 assertTrue(doneCalled)
159 assertNotNull(container.fakeProfileStore.savedProfile)
160 assertEquals("http://localhost:3000", container.fakeProfileStore.savedProfile?.baseUrl)
161 }
163 @Test
164 fun connect_derivesLabelFromHostnameWhenLabelBlank() = testScope.runTest {
165 val container = FakeAppContainer()
166 container.probeResult = ProbeResult.Reachable
167 val vm = makeVm(container)
168 vm.update { it.copy(baseUrl = "192.168.1.10:4096") }
169 vm.connect { }
170 testScheduler.advanceUntilIdle()
171 val saved = container.fakeProfileStore.savedProfile!!
172 assertEquals("192.168.1.10:4096", saved.label)
173 }
175 @Test
176 fun connect_preservesExplicitLabelWhenProvided() = testScope.runTest {
177 val container = FakeAppContainer()
178 container.probeResult = ProbeResult.Reachable
179 val vm = makeVm(container)
180 // The add form has no label field, but the VM still honors a label set via update()
181 // (e.g. by a future caller) — only a blank label is derived.
182 vm.update { it.copy(baseUrl = "http://localhost:3000", label = "My Server") }
183 vm.connect { }
184 testScheduler.advanceUntilIdle()
185 assertEquals("My Server", container.fakeProfileStore.savedProfile?.label)
186 }
188 @Test
189 fun connect_generatesIdForNewProfile() = testScope.runTest {
190 val container = FakeAppContainer()
191 container.probeResult = ProbeResult.Reachable
192 val vm = makeVm(container)
193 vm.update { it.copy(baseUrl = "http://localhost:3000") }
194 vm.connect { }
195 testScheduler.advanceUntilIdle()
196 val saved = container.fakeProfileStore.savedProfile
197 assertNotNull(saved?.id)
198 assertTrue(saved!!.id.isNotEmpty())
199 }
201 @Test
202 fun connect_trimsFields() = testScope.runTest {
203 val container = FakeAppContainer()
204 container.probeResult = ProbeResult.Reachable
205 val vm = makeVm(container)
206 vm.update { it.copy(baseUrl = " http://localhost:3000 ", username = " admin ") }
207 vm.connect { }
208 testScheduler.advanceUntilIdle()
209 val saved = container.fakeProfileStore.savedProfile!!
210 assertEquals("http://localhost:3000", saved.baseUrl)
211 assertEquals("admin", saved.username)
212 }
214 @Test
215 fun connect_appliesHttpsSchemeToBareLanHost() = testScope.runTest {
216 val container = FakeAppContainer()
217 container.probeResult = ProbeResult.Reachable
218 val vm = makeVm(container)
219 vm.update { it.copy(baseUrl = "localhost:3000") }
220 vm.connect { }
221 testScheduler.advanceUntilIdle()
222 val saved = container.fakeProfileStore.savedProfile!!
223 // A bare host is always schemed to https:// (the safe default). The user can still
224 // opt into cleartext by typing http:// explicitly.
225 assertEquals("https://localhost:3000", saved.baseUrl)
226 // The editor's own field is refreshed to the schemed form so a just-saved bare-host
227 // entry doesn't read as dirty against its own stored value on re-open.
228 assertEquals("https://localhost:3000", vm.state.value.baseUrl)
229 assertFalse(vm.state.value.isDirty)
230 }
232 @Test
233 fun connect_appliesHttpsSchemeToBarePublicDomain() = testScope.runTest {
234 val container = FakeAppContainer()
235 container.probeResult = ProbeResult.Reachable
236 val vm = makeVm(container)
237 vm.update { it.copy(baseUrl = "example.com") }
238 vm.connect { }
239 testScheduler.advanceUntilIdle()
240 val saved = container.fakeProfileStore.savedProfile!!
241 assertEquals("https://example.com", saved.baseUrl)
242 }
244 @Test
245 fun connect_preservesExplicitHttpSchemeForPublicDomain() = testScope.runTest {
246 val container = FakeAppContainer()
247 container.probeResult = ProbeResult.Reachable
248 val vm = makeVm(container)
249 // The user typed the scheme explicitly — don't upgrade a public domain to https://
250 // behind their back; preserve their explicit http:// choice.
251 vm.update { it.copy(baseUrl = "http://example.com") }
252 vm.connect { }
253 testScheduler.advanceUntilIdle()
254 val saved = container.fakeProfileStore.savedProfile!!
255 assertEquals("http://example.com", saved.baseUrl)
256 }
258 @Test
259 fun connect_emptyUsernameClearsCredentials() = testScope.runTest {
260 val container = FakeAppContainer()
261 container.probeResult = ProbeResult.Reachable
262 val vm = makeVm(container)
263 vm.update { it.copy(baseUrl = "http://localhost:3000", username = "", password = "") }
264 vm.connect { }
265 testScheduler.advanceUntilIdle()
266 val saved = container.fakeProfileStore.savedProfile!!
267 assertNull(saved.username)
268 assertNull(saved.password)
269 }
271 @Test
272 fun connect_saveFailureSetsErrorAndClearsSaving() = testScope.runTest {
273 val container = FakeAppContainer()
274 container.probeResult = ProbeResult.Reachable
275 container.fakeProfileStore.saveException = RuntimeException("disk full")
276 val vm = makeVm(container)
277 vm.update { it.copy(baseUrl = "http://localhost:3000") }
278 var doneCalled = false
279 vm.connect { doneCalled = true }
280 testScheduler.advanceUntilIdle()
281 assertFalse(doneCalled)
282 assertFalse(vm.state.value.saving)
283 // The error must come from the exception, not from a clobbered ServerEditState.
284 assertNotNull(vm.state.value.error)
285 assertTrue(vm.state.value.error!!.contains("disk full"))
286 }
288 @Test
289 fun connect_saveFailureDoesNotClobberUserEdits() = testScope.runTest {
290 val container = FakeAppContainer()
291 container.probeResult = ProbeResult.Reachable
292 container.fakeProfileStore.saveException = RuntimeException("disk full")
293 val vm = makeVm(container)
294 vm.update { it.copy(baseUrl = "http://localhost:3000", label = "Original") }
295 vm.connect { }
296 // Simulate the user editing the label while the save coroutine is in-flight.
297 vm.update { it.copy(label = "Edited") }
298 testScheduler.advanceUntilIdle()
299 // The error path must not overwrite the user's edit.
300 assertEquals("Edited", vm.state.value.label)
301 }
303 // --- connect(): probe folded into the primary action ---
305 @Test
306 fun connect_reachableServer_savesAndConnects() = testScope.runTest {
307 val container = FakeAppContainer()
308 container.probeResult = ProbeResult.Reachable
309 val vm = makeVm(container)
310 vm.update { it.copy(baseUrl = "http://localhost:3000") }
311 var doneCalled = false
312 vm.connect { doneCalled = true }
313 testScheduler.advanceUntilIdle()
314 assertFalse(vm.state.value.authFieldsVisible)
315 assertNull(vm.state.value.error)
316 assertEquals(1, container.probeCalls.size)
317 // Reachable → the profile is saved and a connection is opened.
318 assertNotNull(container.fakeProfileStore.savedProfile)
319 assertEquals(1, container.connectCalls.size)
320 assertTrue(doneCalled)
321 }
323 @Test
324 fun connect_needsAuth_showsAuthFieldsWithoutSaving() = testScope.runTest {
325 val container = FakeAppContainer()
326 container.probeResult = ProbeResult.NeedsAuth
327 val vm = makeVm(container)
328 vm.update { it.copy(baseUrl = "http://localhost:3000") }
329 var doneCalled = false
330 vm.connect { doneCalled = true }
331 testScheduler.advanceUntilIdle()
332 assertFalse(vm.state.value.saving)
333 assertTrue(vm.state.value.authFieldsVisible)
334 assertNull(vm.state.value.error)
335 // Auth needed: reveal the fields; nothing is saved or connected yet.
336 assertNull(container.fakeProfileStore.savedProfile)
337 assertEquals(0, container.connectCalls.size)
338 assertFalse(doneCalled)
339 }
341 @Test
342 fun connect_unreachable_showsErrorAndDoesNotSave() = testScope.runTest {
343 val container = FakeAppContainer()
344 container.probeResult = ProbeResult.Unreachable("Could not reach server")
345 val vm = makeVm(container)
346 vm.update { it.copy(baseUrl = "http://localhost:3000") }
347 vm.connect { }
348 testScheduler.advanceUntilIdle()
349 assertFalse(vm.state.value.saving)
350 assertFalse(vm.state.value.authFieldsVisible)
351 assertNotNull(vm.state.value.error)
352 assertTrue(vm.state.value.error!!.contains("Could not reach server"))
353 assertNull(container.fakeProfileStore.savedProfile)
354 }
356 @Test
357 fun connect_withCredentials_skipsProbeAndSaves() = testScope.runTest {
358 val container = FakeAppContainer()
359 container.probeResult = ProbeResult.NeedsAuth
360 val vm = makeVm(container)
361 vm.update { it.copy(baseUrl = "http://localhost:3000", username = "admin", password = "secret") }
362 var doneCalled = false
363 vm.connect { doneCalled = true }
364 testScheduler.advanceUntilIdle()
365 // Credentials present: no probe, straight to save + connect.
366 assertEquals(0, container.probeCalls.size)
367 assertNotNull(container.fakeProfileStore.savedProfile)
368 assertEquals(1, container.connectCalls.size)
369 assertTrue(doneCalled)
370 }
372 @Test
373 fun connect_authFieldsVisible_skipsProbe() = testScope.runTest {
374 val container = FakeAppContainer()
375 container.probeResult = ProbeResult.NeedsAuth
376 val vm = makeVm(container)
377 // Simulate a first Connect that revealed auth, then a second Connect with creds typed.
378 vm.update { it.copy(baseUrl = "http://localhost:3000", authFieldsVisible = true) }
379 vm.connect { }
380 testScheduler.advanceUntilIdle()
381 assertEquals(0, container.probeCalls.size)
382 assertNotNull(container.fakeProfileStore.savedProfile)
383 }
385 @Test
386 fun connect_invalidUrlDoesNothing() = testScope.runTest {
387 val container = FakeAppContainer()
388 container.probeResult = ProbeResult.Reachable
389 val vm = makeVm(container)
390 vm.update { it.copy(baseUrl = "not a url") }
391 var doneCalled = false
392 vm.connect { doneCalled = true }
393 testScheduler.advanceUntilIdle()
394 assertEquals(0, container.probeCalls.size)
395 assertNull(container.fakeProfileStore.savedProfile)
396 assertFalse(doneCalled)
397 }
399 @Test
400 fun connect_whileSavingDoesNotConnectAgain() = testScope.runTest {
401 val container = FakeAppContainer()
402 container.probeResult = ProbeResult.Reachable
403 val vm = makeVm(container)
404 vm.update { it.copy(baseUrl = "http://localhost:3000") }
405 vm.connect { }
406 vm.connect { }
407 testScheduler.advanceUntilIdle()
408 assertEquals(1, container.probeCalls.size)
409 }
411 @Test
412 fun connect_setsSavingFlagDuringProbeThenClearsOnAuth() = testScope.runTest {
413 val container = FakeAppContainer()
414 // NeedsAuth ends the flow (reveals auth) rather than navigating away, so saving
415 // returns to false and is observable — the reachable path stays saving until onDone.
416 container.probeResult = ProbeResult.NeedsAuth
417 val vm = makeVm(container)
418 vm.update { it.copy(baseUrl = "http://localhost:3000") }
419 vm.connect { }
420 // Before advancing, the probe is in flight → saving is true.
421 assertTrue(vm.state.value.saving)
422 testScheduler.advanceUntilIdle()
423 assertFalse(vm.state.value.saving)
424 }