git.iko.soy/opencode-android
on 4cdfbda help sign in
639 lines 25 kB history raw
1@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
3package soy.iko.opencode.ui.chat
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.ExperimentalCoroutinesApi
7import kotlinx.coroutines.launch
8import kotlinx.coroutines.flow.first
9import kotlinx.coroutines.flow.toList
10import kotlinx.coroutines.test.StandardTestDispatcher
11import kotlinx.coroutines.test.TestScope
12import kotlinx.coroutines.test.resetMain
13import kotlinx.coroutines.test.runTest
14import kotlinx.coroutines.test.setMain
15import org.junit.After
16import org.junit.Assert.assertEquals
17import org.junit.Assert.assertFalse
18import org.junit.Assert.assertNotNull
19import org.junit.Assert.assertNull
20import org.junit.Assert.assertTrue
21import org.junit.Before
22import org.junit.Test
23import soy.iko.opencode.data.model.Command
24import soy.iko.opencode.data.model.MessageWithParts
25import soy.iko.opencode.data.model.ModelInfo
26import soy.iko.opencode.data.model.Permission
27import soy.iko.opencode.data.model.PermissionResponse
28import soy.iko.opencode.data.model.PermissionUpdated
29import soy.iko.opencode.data.model.Provider
30import soy.iko.opencode.data.model.ProvidersResponse
31import soy.iko.opencode.data.model.ServerProfile
32import soy.iko.opencode.data.model.Session
33import soy.iko.opencode.data.model.SessionDeleted
34import soy.iko.opencode.data.model.SessionError
35import soy.iko.opencode.data.model.SessionIdle
36import soy.iko.opencode.data.model.SessionUpdated
37import soy.iko.opencode.data.model.UserMessage
38import soy.iko.opencode.data.network.EventStreamClient
39import soy.iko.opencode.ui.testing.FakeAppContainer
40import soy.iko.opencode.ui.testing.FakeEventStreamClient
41import soy.iko.opencode.ui.testing.FakeOpencodeApiClient
42import soy.iko.opencode.ui.testing.FakeOpencodeConnection
43import soy.iko.opencode.ui.testing.FakeSessionRepository
44import java.io.IOException
46class ChatViewModelTest {
47 private val testDispatcher = StandardTestDispatcher()
48 private val testScope = TestScope(testDispatcher)
50 @Before
51 fun setUp() {
52 Dispatchers.setMain(testDispatcher)
53 }
55 @After
56 fun tearDown() {
57 Dispatchers.resetMain()
58 }
60 private fun makeContainer(
61 api: FakeOpencodeApiClient = FakeOpencodeApiClient(),
62 events: FakeEventStreamClient = FakeEventStreamClient(),
63 repo: FakeSessionRepository = FakeSessionRepository(api, events),
64 profile: ServerProfile = ServerProfile(id = "s1", label = "Test", baseUrl = "http://localhost"),
65 ): FakeAppContainer {
66 val container = FakeAppContainer()
67 val conn = FakeOpencodeConnection(api, events, repo, profile)
68 container.setActiveConnection(conn)
69 return container
70 }
72 /** Creates a ViewModel and lets its init coroutines start before returning. */
73 private fun makeVm(container: FakeAppContainer, sessionId: String = "session1"): ChatViewModel {
74 val vm = ChatViewModel(container, sessionId)
75 testScope.testScheduler.advanceUntilIdle()
76 return vm
77 }
79 // --- send() ---
81 @Test
82 fun send_queuesToOutboxWhenNotConnected() = testScope.runTest {
83 // The offline-outbox feature changed send()'s contract: when there's no active
84 // connection the prompt is accepted for offline queueing (returns true) rather than
85 // being rejected. (This test previously asserted the old "returns false" behavior.)
86 val container = FakeAppContainer()
87 val vm = makeVm(container)
88 assertTrue(vm.send("hello"))
89 }
91 @Test
92 fun send_returnsFalseForEmptyText() = testScope.runTest {
93 val container = makeContainer()
94 val vm = makeVm(container)
95 assertFalse(vm.send(""))
96 assertFalse(vm.send(" "))
97 }
99 @Test
100 fun send_returnsFalseWhenAlreadyRunning() = testScope.runTest {
101 val container = makeContainer()
102 val vm = makeVm(container)
103 assertTrue(vm.send("first"))
104 assertFalse(vm.send("second"))
105 }
107 @Test
108 fun send_clearsDraftImmediatelyAndSetsRunning() = testScope.runTest {
109 val container = makeContainer()
110 val vm = makeVm(container)
111 vm.updateDraft("hello world")
112 assertTrue(vm.send("hello world"))
113 assertEquals("", vm.draft.value)
114 assertTrue(vm.running.value)
115 }
117 @Test
118 fun send_successPersistsEmptyDraft() = testScope.runTest {
119 val container = makeContainer()
120 val vm = makeVm(container)
121 vm.updateDraft("hello")
122 assertTrue(vm.send("hello"))
123 testScope.testScheduler.advanceUntilIdle()
124 assertEquals("", container.fakeDraftStore.get("session1"))
125 assertNull(vm.failedDraft.value)
126 }
128 @Test
129 fun send_failureRestoresDraftAndClearsRunning() = testScope.runTest {
130 val api = FakeOpencodeApiClient()
131 val events = FakeEventStreamClient()
132 val repo = FakeSessionRepository(api, events)
133 repo.sendPromptThrows = IOException("network error")
134 val container = makeContainer(api = api, events = events, repo = repo)
135 val vm = makeVm(container)
136 vm.updateDraft("hello")
137 assertTrue(vm.send("hello"))
138 testScope.testScheduler.advanceUntilIdle()
139 assertEquals("hello", vm.draft.value)
140 assertEquals("hello", vm.failedDraft.value)
141 assertFalse(vm.running.value)
142 val errors = mutableListOf<ChatError>()
143 val collectJob = launch { vm.errorEvents.toList(errors) }
144 assertTrue(vm.send("hello"))
145 testScheduler.advanceUntilIdle()
146 collectJob.cancel()
147 assertTrue(errors.isNotEmpty())
148 }
150 @Test
151 fun send_failureDoesNotRestoreDraftIfUserTypedNewText() = testScope.runTest {
152 val api = FakeOpencodeApiClient()
153 val events = FakeEventStreamClient()
154 val repo = FakeSessionRepository(api, events)
155 repo.sendPromptThrows = IOException("network error")
156 val container = makeContainer(api = api, events = events, repo = repo)
157 val vm = makeVm(container)
158 vm.updateDraft("original")
159 assertTrue(vm.send("original"))
160 vm.updateDraft("new text")
161 testScope.testScheduler.advanceUntilIdle()
162 assertEquals("new text", vm.draft.value)
163 }
165 // --- retryFailed() ---
167 @Test
168 fun retryFailed_noopWhenNoFailedDraft() = testScope.runTest {
169 val container = makeContainer()
170 val vm = makeVm(container)
171 vm.retryFailed()
172 testScope.testScheduler.advanceUntilIdle()
173 assertFalse(vm.running.value)
174 }
176 @Test
177 fun retryFailed_resendsFailedDraft() = testScope.runTest {
178 val api = FakeOpencodeApiClient()
179 val events = FakeEventStreamClient()
180 val repo = FakeSessionRepository(api, events)
181 repo.sendPromptThrows = IOException("network error")
182 val container = makeContainer(api = api, events = events, repo = repo)
183 val vm = makeVm(container)
184 vm.updateDraft("hello")
185 assertTrue(vm.send("hello"))
186 testScope.testScheduler.advanceUntilIdle()
187 assertEquals("hello", vm.failedDraft.value)
189 repo.sendPromptThrows = null
190 vm.retryFailed()
191 testScope.testScheduler.advanceUntilIdle()
192 assertNull(vm.failedDraft.value)
193 }
195 // --- runCommand() ---
197 @Test
198 fun runCommand_setsRunning() = testScope.runTest {
199 val container = makeContainer()
200 val vm = makeVm(container)
201 vm.runCommand(Command(name = "compact"))
202 assertTrue(vm.running.value)
203 }
205 @Test
206 fun runCommand_rejectedWhenAlreadyRunning() = testScope.runTest {
207 val container = makeContainer()
208 val vm = makeVm(container)
209 assertTrue(vm.send("hello"))
210 assertFalse(vm.running.value.not()) // running is true
211 vm.runCommand(Command(name = "compact"))
212 // Still running from the first send; runCommand should not override
213 assertTrue(vm.running.value)
214 }
216 // --- abort() ---
218 @Test
219 fun abort_clearsRunning() = testScope.runTest {
220 val container = makeContainer()
221 val vm = makeVm(container)
222 assertTrue(vm.send("hello"))
223 testScope.testScheduler.advanceUntilIdle()
224 vm.abort()
225 testScope.testScheduler.advanceUntilIdle()
226 assertFalse(vm.running.value)
227 }
229 @Test
230 fun abort_emitsStopUndoWithLastPrompt() = testScope.runTest {
231 val container = makeContainer()
232 val vm = makeVm(container)
233 assertTrue(vm.send("hello there"))
234 testScope.testScheduler.advanceUntilIdle()
235 vm.abort()
236 testScope.testScheduler.advanceUntilIdle()
237 // The undo event carries the last-sent prompt so the UI can show "Stopped — Undo".
238 val prompt = vm.stopUndoEvents.first()
239 assertEquals("hello there", prompt)
240 }
242 @Test
243 fun resendLastPrompt_reSendsTheTrackedPrompt() = testScope.runTest {
244 val container = makeContainer()
245 val vm = makeVm(container)
246 assertTrue(vm.send("original prompt"))
247 testScope.testScheduler.advanceUntilIdle()
248 // Abort surfaces the undo event; resendLastPrompt re-sends the tracked text.
249 vm.abort()
250 testScope.testScheduler.advanceUntilIdle()
251 vm.resendLastPrompt()
252 testScope.testScheduler.advanceUntilIdle()
253 // The send should have re-queued the prompt (running re-lights).
254 assertTrue(vm.running.value)
255 }
257 // --- respondPermission() ---
259 @Test
260 fun respondPermission_clearsPendingOptimistically() = testScope.runTest {
261 val events = FakeEventStreamClient()
262 val container = makeContainer(events = events)
263 val vm = makeVm(container)
264 val perm = Permission(id = "p1", sessionID = "session1")
266 events.fakeEvents.tryEmit(PermissionUpdated(perm))
267 testScope.testScheduler.advanceUntilIdle()
268 assertEquals("p1", vm.pendingPermission.value?.id)
270 vm.respondPermission(perm, PermissionResponse.ONCE)
271 assertNull(vm.pendingPermission.value)
272 }
274 @Test
275 fun respondPermission_restoresPendingOnFailure() = testScope.runTest {
276 val api = FakeOpencodeApiClient()
277 api.respondPermissionThrows = IOException("network error")
278 val container = makeContainer(api = api)
279 val vm = makeVm(container)
280 val perm = Permission(id = "p1", sessionID = "session1")
282 val events = (container.activeConnection.value!!.events as FakeEventStreamClient)
283 events.fakeEvents.tryEmit(PermissionUpdated(perm))
284 testScope.testScheduler.advanceUntilIdle()
285 assertEquals("p1", vm.pendingPermission.value?.id)
287 val errors = mutableListOf<ChatError>()
288 val collectJob = launch { vm.errorEvents.toList(errors) }
289 vm.respondPermission(perm, PermissionResponse.ONCE)
290 assertNull(vm.pendingPermission.value)
291 testScope.testScheduler.advanceUntilIdle()
292 collectJob.cancel()
293 assertEquals("p1", vm.pendingPermission.value?.id)
294 assertTrue(errors.isNotEmpty())
295 }
297 // --- SSE event handling ---
299 @Test
300 fun sseSessionIdle_resetsRunning() = testScope.runTest {
301 val events = FakeEventStreamClient()
302 val container = makeContainer(events = events)
303 val vm = makeVm(container)
304 assertTrue(vm.send("hello"))
305 testScope.testScheduler.advanceUntilIdle()
306 events.fakeEvents.tryEmit(SessionIdle(SessionIdle.Props(sessionID = "session1")))
307 testScope.testScheduler.advanceUntilIdle()
308 assertFalse(vm.running.value)
309 }
311 @Test
312 fun sseSessionError_resetsRunningAndEmitsError() = testScope.runTest {
313 val events = FakeEventStreamClient()
314 val container = makeContainer(events = events)
315 val vm = makeVm(container)
316 val errors = mutableListOf<ChatError>()
317 val collectJob = launch { vm.errorEvents.toList(errors) }
318 assertTrue(vm.send("hello"))
319 testScope.testScheduler.advanceUntilIdle()
320 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
321 testScope.testScheduler.advanceUntilIdle()
322 collectJob.cancel()
323 assertFalse(vm.running.value)
324 assertTrue(errors.isNotEmpty())
325 }
327 @Test
328 fun sseSessionIdle_ignoresOtherSession() = testScope.runTest {
329 val events = FakeEventStreamClient()
330 val container = makeContainer(events = events)
331 val vm = makeVm(container)
332 assertTrue(vm.send("hello"))
333 testScope.testScheduler.advanceUntilIdle()
334 events.fakeEvents.tryEmit(SessionIdle(SessionIdle.Props(sessionID = "other")))
335 testScope.testScheduler.advanceUntilIdle()
336 assertTrue(vm.running.value)
337 }
339 @Test
340 fun sseSessionUpdated_updatesTitle() = testScope.runTest {
341 val events = FakeEventStreamClient()
342 val container = makeContainer(events = events)
343 val vm = makeVm(container)
344 events.fakeEvents.tryEmit(
345 SessionUpdated(SessionUpdated.Props(info = Session(id = "session1", title = "My Chat"))),
346 )
347 testScope.testScheduler.advanceUntilIdle()
348 assertEquals("My Chat", vm.sessionTitle.value)
349 }
351 @Test
352 fun sseSessionDeleted_setsDeletedFlag() = testScope.runTest {
353 val events = FakeEventStreamClient()
354 val container = makeContainer(events = events)
355 val vm = makeVm(container)
356 events.fakeEvents.tryEmit(
357 SessionDeleted(SessionDeleted.Props(sessionID = "session1")),
358 )
359 testScope.testScheduler.advanceUntilIdle()
360 assertTrue(vm.sessionDeleted.value)
361 }
363 // --- Connection state ---
365 @Test
366 fun sseDisconnected_keepsRunningAndFlagsInterrupted() = testScope.runTest {
367 val events = FakeEventStreamClient()
368 val container = makeContainer(events = events)
369 val vm = makeVm(container)
370 assertTrue(vm.send("hello"))
371 testScope.testScheduler.advanceUntilIdle()
372 events.fakeState.value = EventStreamClient.ConnectionState.Disconnected
373 testScope.testScheduler.advanceUntilIdle()
374 // A transient SSE drop does NOT clear _running — the run is still active on the
375 // server, and showing it as finished would mislead the user. Instead the stream-
376 // interrupted flag is set so the working row shows a "reconnecting" visual.
377 assertTrue(vm.running.value)
378 assertTrue(vm.streamInterrupted.value)
379 // Reconnecting clears the interrupted flag but keeps running.
380 events.fakeState.value = EventStreamClient.ConnectionState.Connected
381 testScope.testScheduler.advanceUntilIdle()
382 assertTrue(vm.running.value)
383 assertFalse(vm.streamInterrupted.value)
384 }
386 @Test
387 fun sseFailed_resetsRunning() = testScope.runTest {
388 val events = FakeEventStreamClient()
389 val container = makeContainer(events = events)
390 val vm = makeVm(container)
391 assertTrue(vm.send("hello"))
392 testScope.testScheduler.advanceUntilIdle()
393 events.fakeState.value = EventStreamClient.ConnectionState.Failed
394 testScope.testScheduler.advanceUntilIdle()
395 assertFalse(vm.running.value)
396 }
398 // --- Draft persistence ---
400 @Test
401 fun updateDraft_doesNotPersistImmediately() = testScope.runTest {
402 val container = makeContainer()
403 val vm = makeVm(container)
404 vm.updateDraft("typing")
405 assertEquals("", container.fakeDraftStore.get("session1"))
406 assertEquals("typing", vm.draft.value)
407 }
409 @Test
410 fun updateDraft_persistsAfterDebounce() = testScope.runTest {
411 val container = makeContainer()
412 val vm = makeVm(container)
413 vm.updateDraft("typing")
414 testScope.testScheduler.advanceUntilIdle()
415 assertEquals("typing", container.fakeDraftStore.get("session1"))
416 }
418 @Test
419 fun onCleared_flushesPendingDraft() = testScope.runTest {
420 val container = makeContainer()
421 val vm = makeVm(container)
422 vm.updateDraft("unsent draft")
423 testScope.testScheduler.advanceUntilIdle()
424 // onCleared() is protected — invoke via reflection
425 val method = androidx.lifecycle.ViewModel::class.java.getDeclaredMethod("onCleared")
426 method.isAccessible = true
427 method.invoke(vm)
428 assertEquals("unsent draft", container.fakeDraftStore.lastFlushed?.second)
429 }
431 // --- Catalog loading ---
433 @Test
434 fun catalogs_loadFromApi() = testScope.runTest {
435 val api = FakeOpencodeApiClient()
436 api.agentsList = listOf(soy.iko.opencode.data.model.Agent(name = "code"))
437 api.commandsList = listOf(Command(name = "compact"))
438 val container = makeContainer(api = api)
439 val vm = makeVm(container)
440 testScope.testScheduler.advanceUntilIdle()
441 assertEquals(1, vm.agents.value.size)
442 assertEquals("code", vm.agents.value[0].name)
443 assertEquals(1, vm.commands.value.size)
444 }
446 @Test
447 fun reloadModels_invalidatesCacheAndRefetches() = testScope.runTest {
448 val api = FakeOpencodeApiClient()
449 val container = makeContainer(api = api)
450 val vm = makeVm(container)
451 testScope.testScheduler.advanceUntilIdle()
452 assertFalse(api.providersCacheInvalidated)
454 api.providersResponse = ProvidersResponse(
455 providers = listOf(
456 Provider(id = "p1", name = "Provider 1", models = mapOf("m1" to ModelInfo(id = "m1", name = "Model 1"))),
457 ),
458 )
459 vm.reloadModels()
460 testScope.testScheduler.advanceUntilIdle()
461 assertTrue(api.providersCacheInvalidated)
462 assertTrue(vm.models.value.isNotEmpty())
463 }
465 // --- reconnect() ---
467 @Test
468 fun reconnect_connectsToMostRecentProfile() = testScope.runTest {
469 val container = FakeAppContainer()
470 container.fakeProfileStore.setProfiles(
471 listOf(ServerProfile(id = "p1", label = "Server 1", baseUrl = "http://localhost")),
472 )
473 val vm = makeVm(container)
474 vm.reconnect()
475 testScope.testScheduler.advanceUntilIdle()
476 assertEquals(1, container.connectCalls.size)
477 assertEquals("p1", container.connectCalls[0].id)
478 }
480 @Test
481 fun reconnect_withoutProfilesDoesNothing() = testScope.runTest {
482 val container = FakeAppContainer()
483 val vm = makeVm(container)
484 vm.reconnect()
485 testScope.testScheduler.advanceUntilIdle()
486 assertEquals(0, container.connectCalls.size)
487 }
489 @Test
490 fun reconnect_skipsWhenAlreadyConnected() = testScope.runTest {
491 val container = makeContainer()
492 val vm = makeVm(container)
493 vm.reconnect()
494 testScope.testScheduler.advanceUntilIdle()
495 assertEquals(0, container.connectCalls.size)
496 }
498 // --- Connection reset on server switch ---
500 @Test
501 fun connectionChange_resetsRunningAndErrorAndPermission() = testScope.runTest {
502 val events = FakeEventStreamClient()
503 val container = makeContainer(events = events)
504 val vm = makeVm(container)
505 assertTrue(vm.send("hello"))
506 testScope.testScheduler.advanceUntilIdle()
507 // Switch to a new connection
508 val api2 = FakeOpencodeApiClient()
509 val events2 = FakeEventStreamClient()
510 val repo2 = FakeSessionRepository(api2, events2)
511 val conn2 = FakeOpencodeConnection(api2, events2, repo2, ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
512 container.setActiveConnection(conn2)
513 testScope.testScheduler.advanceUntilIdle()
514 assertFalse(vm.running.value)
515 assertNull(vm.pendingPermission.value)
516 }
518 // --- rapid successive errors ---
520 @Test
521 fun rapidSuccessiveErrors_bothDeliveredAsEvents() = testScope.runTest {
522 val api = FakeOpencodeApiClient()
523 val events = FakeEventStreamClient()
524 val repo = FakeSessionRepository(api, events)
525 repo.sendPromptThrows = IOException("err")
526 val container = makeContainer(api = api, events = events, repo = repo)
527 val vm = makeVm(container)
529 val errors = mutableListOf<ChatError>()
530 val collectJob = launch { vm.errorEvents.toList(errors) }
532 // Two rapid sends that both fail — both should produce independent error
533 // events, not just the last one (the bug with StateFlow-keyed effects).
534 assertTrue(vm.send("first"))
535 testScheduler.advanceUntilIdle()
536 assertTrue(vm.send("second"))
537 testScheduler.advanceUntilIdle()
539 collectJob.cancel()
540 assertEquals(2, errors.size)
541 }
543 @Test
544 fun messagesFlowRetry_emitsErrorOnFailure() = testScope.runTest {
545 val api = FakeOpencodeApiClient()
546 val events = FakeEventStreamClient()
547 // Use a controllable flow so we can simulate a re-emission after an error.
548 val messagesFlow = kotlinx.coroutines.flow.MutableStateFlow(
549 listOf(MessageWithParts(info = UserMessage(id = "m1", sessionID = "session1")))
550 )
551 val repo = FakeSessionRepository(api, events)
552 repo.observeMessagesOverride = messagesFlow
553 val container = makeContainer(api = api, events = events, repo = repo)
554 val vm = makeVm(container)
555 val errors = mutableListOf<ChatError>()
556 val collectJob = launch { vm.errorEvents.toList(errors) }
557 // Subscribe to messages so the flow's retryWhen is active.
558 val collector = launch { vm.messages.collect { /* keep subscribed */ } }
559 testScope.testScheduler.advanceUntilIdle()
560 // Set an error via SSE
561 events.fakeEvents.tryEmit(SessionError(SessionError.Props(sessionID = "session1")))
562 testScope.testScheduler.advanceUntilIdle()
563 collectJob.cancel()
564 collector.cancel()
565 assertTrue(errors.isNotEmpty())
566 }
568 // --- Connection switch clears session title ---
570 @Test
571 fun connectionChange_clearsSessionTitle() = testScope.runTest {
572 val events = FakeEventStreamClient()
573 val container = makeContainer(events = events)
574 val vm = makeVm(container)
575 // Set the title via SSE event
576 events.fakeEvents.tryEmit(
577 SessionUpdated(SessionUpdated.Props(info = Session(id = "session1", title = "Old Title"))),
578 )
579 testScope.testScheduler.advanceUntilIdle()
580 assertEquals("Old Title", vm.sessionTitle.value)
581 // Switch to a new connection
582 val api2 = FakeOpencodeApiClient()
583 val events2 = FakeEventStreamClient()
584 val repo2 = FakeSessionRepository(api2, events2)
585 val conn2 = FakeOpencodeConnection(api2, events2, repo2, ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
586 container.setActiveConnection(conn2)
587 testScope.testScheduler.advanceUntilIdle()
588 assertNull(vm.sessionTitle.value)
589 }
591 // --- editMessage ---
593 @Test
594 fun editMessage_prefillsDraftWithText() = testScope.runTest {
595 val container = makeContainer()
596 val vm = makeVm(container)
597 vm.editMessage("m1", "the original prompt")
598 testScheduler.advanceUntilIdle()
599 assertEquals("the original prompt", vm.draft.value)
600 }
602 // --- attachment persistence ---
604 private fun makeAttachment(id: String) = PendingAttachment(
605 id = id,
606 name = "$id.txt",
607 mime = "text/plain",
608 previewModel = null,
609 part = soy.iko.opencode.data.model.FilePromptPart(
610 mime = "text/plain",
611 url = "data:text/plain;base64,QUJD",
612 filename = "$id.txt",
613 ),
614 )
616 @Test
617 fun addAttachment_persistsToStore() = testScope.runTest {
618 val container = makeContainer()
619 val vm = makeVm(container)
620 vm.addAttachment(makeAttachment("a1"))
621 testScheduler.advanceUntilIdle()
622 assertEquals(1, container.fakeAttachmentDraftStore.load("session1").size)
623 }
625 @Test
626 fun attachments_restoredFromStoreOnInit() = testScope.runTest {
627 val container = makeContainer()
628 // Pre-populate the store as if a prior compose was interrupted by process death.
629 val att = makeAttachment("a1")
630 container.fakeAttachmentDraftStore.save(
631 "session1",
632 listOf(soy.iko.opencode.data.repo.PersistedAttachment(att.id, att.name, att.mime, att.part.url, att.part.filename)),
633 )
634 val vm = makeVm(container)
635 testScheduler.advanceUntilIdle()
636 assertEquals(1, vm.attachments.value.size)
637 assertEquals("a1", vm.attachments.value[0].id)
638 }