git.iko.soy/opencode-android
on 5d56632 help sign in
464 lines 19 kB history raw
1@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
3package soy.iko.opencode.ui.testing
5import kotlinx.coroutines.flow.Flow
6import kotlinx.coroutines.flow.MutableSharedFlow
7import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.StateFlow
9import kotlinx.coroutines.flow.asSharedFlow
10import kotlinx.coroutines.flow.asStateFlow
11import kotlinx.coroutines.flow.flowOf
12import soy.iko.opencode.data.model.Agent
13import soy.iko.opencode.data.model.BusEvent
14import soy.iko.opencode.data.model.Command
15import soy.iko.opencode.data.model.FileContent
16import soy.iko.opencode.data.model.FileNode
17import soy.iko.opencode.data.model.FileStatusEntry
18import soy.iko.opencode.data.model.MessageWithParts
19import soy.iko.opencode.data.model.ModelRef
20import soy.iko.opencode.data.model.PathInfo
21import soy.iko.opencode.data.model.PermissionResponse
22import soy.iko.opencode.data.model.Project
23import soy.iko.opencode.data.model.ProvidersResponse
24import soy.iko.opencode.data.model.ServerProfile
25import soy.iko.opencode.data.model.Session
26import soy.iko.opencode.data.network.EventStreamClient
27import soy.iko.opencode.data.network.NetworkConfig
28import soy.iko.opencode.data.network.OpencodeApiClient
29import soy.iko.opencode.data.repo.AttachmentDraftStore
30import soy.iko.opencode.data.repo.DraftStore
31import soy.iko.opencode.data.repo.MessageCacheStore
32import soy.iko.opencode.data.repo.OutboxStore
33import soy.iko.opencode.data.repo.PersistedAttachment
34import soy.iko.opencode.data.repo.ProfileStore
35import soy.iko.opencode.data.repo.SessionPrefsStore
36import soy.iko.opencode.data.repo.SessionRepository
37import soy.iko.opencode.di.AppContainer
38import soy.iko.opencode.di.OpencodeConnection
39import soy.iko.opencode.di.ProbeResult
41// ---------------------------------------------------------------------------
42// FakeDraftStore
43// ---------------------------------------------------------------------------
45class FakeDraftStore : DraftStore() {
46 private val store = mutableMapOf<String, String>()
47 private val _ready = MutableStateFlow(true)
48 override val ready: StateFlow<Boolean> = _ready.asStateFlow()
50 var lastFlushed: Pair<String, String>? = null
51 private set
53 override fun get(sessionId: String): String = store[sessionId].orEmpty()
55 override suspend fun set(sessionId: String, text: String) {
56 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
57 }
59 override fun setImmediate(sessionId: String, text: String) {
60 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
61 }
63 override suspend fun remove(sessionId: String) {
64 store.remove(sessionId)
65 }
67 override fun flushDraft(sessionId: String, text: String) {
68 lastFlushed = sessionId to text
69 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
70 }
72 override fun shutdown() { /* no-op */ }
75// ---------------------------------------------------------------------------
76// FakeProfileStore
77// ---------------------------------------------------------------------------
79class FakeProfileStore : ProfileStore() {
80 private val _profiles = MutableStateFlow<List<ServerProfile>>(emptyList())
82 override val profiles: Flow<List<ServerProfile>> = _profiles.asStateFlow()
84 fun setProfiles(profiles: List<ServerProfile>) {
85 _profiles.value = profiles
86 }
88 var savedProfile: ServerProfile? = null
89 private set
91 /** When non-null, [save] throws this exception instead of persisting. */
92 var saveException: Exception? = null
94 override suspend fun resolve(profile: ServerProfile): ServerProfile = profile
96 override suspend fun save(profile: ServerProfile) {
97 saveException?.let { throw it }
98 savedProfile = profile
99 val current = _profiles.value.toMutableList()
100 current.removeAll { it.id == profile.id }
101 current.add(profile)
102 _profiles.value = current
103 }
105 override suspend fun delete(id: String) {
106 _profiles.value = _profiles.value.filterNot { it.id == id }
107 }
110// ---------------------------------------------------------------------------
111// FakeOpencodeApiClient
112// ---------------------------------------------------------------------------
114class FakeOpencodeApiClient : OpencodeApiClient() {
115 var pingThrows: Throwable? = null
116 var sessions: List<Session> = emptyList()
117 var createdSession: Session = Session(id = "new-session", title = "New")
118 var deletedSessions: List<String> = emptyList()
119 var messages: List<MessageWithParts> = emptyList()
120 var providersResponse: ProvidersResponse = ProvidersResponse()
121 var agentsList: List<Agent> = emptyList()
122 var commandsList: List<Command> = emptyList()
123 var directoryListing: List<FileNode> = emptyList()
124 var fileSearchResults: List<String> = emptyList()
125 var fileStatusEntries: List<FileStatusEntry> = emptyList()
126 var fileContent: FileContent = FileContent(content = "")
127 var projectsList: List<Project> = emptyList()
128 var pathInfo: PathInfo = PathInfo()
129 var lastCreatedDirectory: String? = null
130 private set
131 var sendPromptThrows: Throwable? = null
132 var respondPermissionThrows: Throwable? = null
133 var cacheInvalidated = false
134 var providersCacheInvalidated = false
135 private set
136 var agentsCacheInvalidated = false
137 private set
138 var commandsCacheInvalidated = false
139 private set
141 var sendPromptCalls: List<Triple<String, String, ModelRef?>> = emptyList()
142 private set
143 var runCommandCalls: List<Triple<String, String, String?>> = emptyList()
144 private set
145 var respondPermissionCalls: List<Triple<String, String, PermissionResponse>> = emptyList()
146 private set
148 override suspend fun ping() {
149 pingThrows?.let { throw it }
150 }
152 override suspend fun listSessions(): List<Session> = sessions
154 override suspend fun createSession(title: String?, directory: String?): Session {
155 lastCreatedDirectory = directory
156 return createdSession
157 }
159 override suspend fun listProjects(): List<Project> = projectsList
160 override suspend fun currentPath(): PathInfo = pathInfo
162 override suspend fun updateSession(id: String, title: String): Session {
163 return sessions.firstOrNull { it.id == id }?.copy(title = title) ?: Session(id = id, title = title)
164 }
166 override suspend fun deleteSession(id: String) {
167 deletedSessions = deletedSessions + id
168 }
170 var listMessagesCalls: List<String> = emptyList()
171 private set
173 override suspend fun listMessages(sessionId: String): List<MessageWithParts> {
174 listMessagesCalls = listMessagesCalls + sessionId
175 return messages
176 }
178 /** Idempotency keys seen across sendPrompt calls, in order, so tests can assert a
179 * re-send reuses the same key. */
180 var sendPromptKeys: List<String?> = emptyList()
181 private set
183 override suspend fun sendPrompt(
184 sessionId: String,
185 text: String,
186 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
187 model: ModelRef?,
188 agent: String?,
189 idempotencyKey: String?,
190 ): MessageWithParts {
191 sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model)
192 sendPromptKeys = sendPromptKeys + idempotencyKey
193 sendPromptThrows?.let { throw it }
194 return MessageWithParts(
195 info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId),
196 parts = emptyList(),
197 )
198 }
200 override suspend fun abort(sessionId: String) { /* no-op */ }
202 override suspend fun runCommand(
203 sessionId: String,
204 command: String,
205 arguments: String,
206 agent: String?,
207 ): MessageWithParts {
208 runCommandCalls = runCommandCalls + Triple(sessionId, command, agent)
209 return MessageWithParts(
210 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-${runCommandCalls.size}", sessionID = sessionId),
211 parts = emptyList(),
212 )
213 }
215 override suspend fun providers(): ProvidersResponse = providersResponse
216 override suspend fun agents(): List<Agent> = agentsList
217 override suspend fun commands(): List<Command> = commandsList
219 override suspend fun invalidateCache() { cacheInvalidated = true }
220 override suspend fun invalidateProvidersCache() { providersCacheInvalidated = true }
221 override suspend fun invalidateAgentsCache() { agentsCacheInvalidated = true }
222 override suspend fun invalidateCommandsCache() { commandsCacheInvalidated = true }
224 override suspend fun respondPermission(
225 sessionId: String,
226 permissionId: String,
227 response: PermissionResponse,
228 ): String {
229 respondPermissionCalls = respondPermissionCalls + Triple(sessionId, permissionId, response)
230 respondPermissionThrows?.let { throw it }
231 return ""
232 }
234 override suspend fun findFiles(query: String): List<String> = fileSearchResults
235 override suspend fun listDirectory(path: String): List<FileNode> = directoryListing
236 override suspend fun readFile(path: String): FileContent = fileContent
237 override suspend fun fileStatus(): List<FileStatusEntry> = fileStatusEntries
240// ---------------------------------------------------------------------------
241// FakeEventStreamClient
242// ---------------------------------------------------------------------------
244class FakeEventStreamClient : EventStreamClient() {
245 val fakeState = MutableStateFlow(EventStreamClient.ConnectionState.Connected)
246 override val state: StateFlow<EventStreamClient.ConnectionState> = fakeState.asStateFlow()
248 val fakeEvents = MutableSharedFlow<BusEvent>(extraBufferCapacity = NetworkConfig.sseEventBufferCapacity)
249 override val events: kotlinx.coroutines.flow.SharedFlow<BusEvent> = fakeEvents.asSharedFlow()
251 override fun triggerReconnect() { /* no-op */ }
254// ---------------------------------------------------------------------------
255// FakeSessionRepository
256// ---------------------------------------------------------------------------
258class FakeSessionRepository(
259 private val api: FakeOpencodeApiClient,
260 private val eventStream: FakeEventStreamClient,
261) : SessionRepository(api, eventStream) {
262 var sessions: List<Session> = emptyList()
263 var createdSession: Session = Session(id = "new-session", title = "New")
264 var messages: List<MessageWithParts> = emptyList()
265 var sendPromptThrows: Throwable? = null
266 var listSessionsThrows: Throwable? = null
267 /** Optional gate that [listSessions] awaits before returning, so tests can hold a refresh in flight. */
268 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
269 var abortCalls: List<String> = emptyList()
270 private set
271 var lastCreatedDirectory: String? = null
272 private set
274 /** When non-null, [observeMessages] returns this flow instead of [messages]. */
275 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
277 override suspend fun listSessions(): List<Session> {
278 listSessionsGate?.await()
279 listSessionsThrows?.let { throw it }
280 return sessions
281 }
282 override suspend fun createSession(title: String?, directory: String?): Session {
283 lastCreatedDirectory = directory
284 return createdSession
285 }
286 override suspend fun deleteSession(id: String) { /* no-op */ }
287 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
289 override suspend fun sendPrompt(
290 sessionId: String,
291 text: String,
292 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
293 model: ModelRef?,
294 agent: String?,
295 idempotencyKey: String?,
296 ): MessageWithParts {
297 sendPromptThrows?.let { throw it }
298 return MessageWithParts(
299 info = soy.iko.opencode.data.model.UserMessage(id = "msg-1", sessionID = sessionId),
300 parts = emptyList(),
301 )
302 }
304 override suspend fun runCommand(
305 sessionId: String,
306 command: String,
307 arguments: String,
308 agent: String?,
309 ): MessageWithParts = MessageWithParts(
310 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-1", sessionID = sessionId),
311 parts = emptyList(),
312 )
314 override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> =
315 observeMessagesOverride ?: flowOf(messages)
318// ---------------------------------------------------------------------------
319// FakeOpencodeConnection
320// ---------------------------------------------------------------------------
322class FakeOpencodeConnection(
323 override val api: FakeOpencodeApiClient,
324 override val events: FakeEventStreamClient,
325 override val repository: FakeSessionRepository,
326 profile: ServerProfile,
327) : OpencodeConnection(profile) {
328 override suspend fun close() { /* no-op */ }
331// ---------------------------------------------------------------------------
332// FakeSessionPrefsStore
333// ---------------------------------------------------------------------------
335class FakeSessionPrefsStore : SessionPrefsStore() {
336 private val _pinned = MutableStateFlow<Set<String>>(emptySet())
337 private val _archived = MutableStateFlow<Set<String>>(emptySet())
339 override val pinned: Flow<Set<String>> = _pinned.asStateFlow()
340 override val archived: Flow<Set<String>> = _archived.asStateFlow()
342 override suspend fun setPinned(sessionId: String, pinned: Boolean) {
343 _pinned.value = if (pinned) _pinned.value + sessionId else _pinned.value - sessionId
344 }
346 override suspend fun setArchived(sessionId: String, archived: Boolean) {
347 _archived.value = if (archived) _archived.value + sessionId else _archived.value - sessionId
348 }
350 override suspend fun forget(sessionId: String) {
351 _pinned.value = _pinned.value - sessionId
352 _archived.value = _archived.value - sessionId
353 }
356// ---------------------------------------------------------------------------
357// FakeAttachmentDraftStore / FakeMessageCacheStore
358// ---------------------------------------------------------------------------
360class FakeAttachmentDraftStore : AttachmentDraftStore() {
361 private val store = mutableMapOf<String, List<PersistedAttachment>>()
363 override suspend fun load(sessionId: String): List<PersistedAttachment> = store[sessionId].orEmpty()
365 override suspend fun save(sessionId: String, attachments: List<PersistedAttachment>) {
366 if (attachments.isEmpty()) store.remove(sessionId) else store[sessionId] = attachments
367 }
369 override suspend fun remove(sessionId: String) { store.remove(sessionId) }
372class FakeMessageCacheStore : MessageCacheStore() {
373 private val store = mutableMapOf<String, List<MessageWithParts>>()
375 override suspend fun load(sessionId: String): List<MessageWithParts> = store[sessionId].orEmpty()
377 override suspend fun save(sessionId: String, messages: List<MessageWithParts>) {
378 if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages
379 }
381 override suspend fun remove(sessionId: String) { store.remove(sessionId) }
384// ---------------------------------------------------------------------------
385// FakeAppContainer
386// ---------------------------------------------------------------------------
388class FakeAppContainer : AppContainer() {
389 val fakeDraftStore = FakeDraftStore()
390 val fakeProfileStore = FakeProfileStore()
391 val fakeSessionPrefsStore = FakeSessionPrefsStore()
392 val fakeAttachmentDraftStore = FakeAttachmentDraftStore()
393 val fakeMessageCacheStore = FakeMessageCacheStore()
394 // In-memory outbox (no Context / disk); exercises the same reduce logic the app uses.
395 val fakeOutboxStore = object : OutboxStore() {}
396 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
397 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
399 override val draftStore: DraftStore = fakeDraftStore
400 override val profileStore: ProfileStore = fakeProfileStore
401 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
402 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
403 override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
404 override val outboxStore: OutboxStore = fakeOutboxStore
405 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
406 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
408 var connectResult: OpencodeConnection? = null
409 /** When non-null, [connect] throws this instead of returning a connection. */
410 var connectException: Throwable? = null
411 var connectCalls: List<ServerProfile> = emptyList()
412 private set
413 var disconnectCalls = 0
414 private set
416 /** When non-null, [probeServer] returns this instead of building a real client. */
417 var probeResult: ProbeResult? = null
418 var probeCalls: List<String> = emptyList()
419 private set
421 /** When non-null, [probeWithCredentials] returns this directly. */
422 var probeWithCredentialsResult: Boolean? = null
423 var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
424 private set
426 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
428 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
430 override fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
431 "test-error: ${t.message ?: t::class.simpleName}"
433 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
434 connectCalls = connectCalls + profile
435 connectException?.let { throw it }
436 val conn = connectResult ?: run {
437 val api = FakeOpencodeApiClient()
438 val events = FakeEventStreamClient()
439 val repo = FakeSessionRepository(api, events)
440 FakeOpencodeConnection(api, events, repo, profile)
441 }
442 fakeActiveConnection.value = conn
443 return conn
444 }
446 override suspend fun disconnect() {
447 disconnectCalls++
448 fakeActiveConnection.value = null
449 }
451 override suspend fun probeServer(baseUrl: String): ProbeResult {
452 probeCalls = probeCalls + baseUrl
453 return probeResult ?: ProbeResult.Reachable
454 }
456 override suspend fun probeWithCredentials(baseUrl: String, username: String, password: String): Boolean {
457 probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
458 return probeWithCredentialsResult ?: true
459 }
461 fun setActiveConnection(conn: OpencodeConnection?) {
462 fakeActiveConnection.value = conn
463 }