git.iko.soy/opencode-android
on 4cdfbda help sign in
480 lines 20 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 kotlinx.coroutines.flow.update
13import soy.iko.opencode.data.model.Agent
14import soy.iko.opencode.data.model.BusEvent
15import soy.iko.opencode.data.model.Command
16import soy.iko.opencode.data.model.FileContent
17import soy.iko.opencode.data.model.FileNode
18import soy.iko.opencode.data.model.FileStatusEntry
19import soy.iko.opencode.data.model.MessageWithParts
20import soy.iko.opencode.data.model.ModelRef
21import soy.iko.opencode.data.model.PathInfo
22import soy.iko.opencode.data.model.PermissionResponse
23import soy.iko.opencode.data.model.Project
24import soy.iko.opencode.data.model.ProvidersResponse
25import soy.iko.opencode.data.model.ServerProfile
26import soy.iko.opencode.data.model.Session
27import soy.iko.opencode.data.network.EventStreamClient
28import soy.iko.opencode.data.network.NetworkConfig
29import soy.iko.opencode.data.network.OpencodeApiClient
30import soy.iko.opencode.data.repo.AttachmentDraftStore
31import soy.iko.opencode.data.repo.DraftStore
32import soy.iko.opencode.data.repo.MessageCacheStore
33import soy.iko.opencode.data.repo.OutboxStore
34import soy.iko.opencode.data.repo.PersistedAttachment
35import soy.iko.opencode.data.repo.ProfileStore
36import soy.iko.opencode.data.repo.SessionPrefsStore
37import soy.iko.opencode.data.repo.SessionRepository
38import soy.iko.opencode.di.AppContainer
39import soy.iko.opencode.di.OpencodeConnection
40import soy.iko.opencode.di.ProbeResult
42// ---------------------------------------------------------------------------
43// FakeDraftStore
44// ---------------------------------------------------------------------------
46class FakeDraftStore : DraftStore() {
47 private val store = mutableMapOf<String, String>()
48 private val _ready = MutableStateFlow(true)
49 override val ready: StateFlow<Boolean> = _ready.asStateFlow()
51 var lastFlushed: Pair<String, String>? = null
52 private set
54 override fun get(sessionId: String): String = store[sessionId].orEmpty()
56 override suspend fun set(sessionId: String, text: String) {
57 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
58 }
60 override fun setImmediate(sessionId: String, text: String) {
61 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
62 }
64 override suspend fun remove(sessionId: String) {
65 store.remove(sessionId)
66 }
68 override fun flushDraft(sessionId: String, text: String) {
69 lastFlushed = sessionId to text
70 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
71 }
73 override fun shutdown() { /* no-op */ }
76// ---------------------------------------------------------------------------
77// FakeProfileStore
78// ---------------------------------------------------------------------------
80class FakeProfileStore : ProfileStore() {
81 private val _profiles = MutableStateFlow<List<ServerProfile>>(emptyList())
83 override val profiles: Flow<List<ServerProfile>> = _profiles.asStateFlow()
85 fun setProfiles(profiles: List<ServerProfile>) {
86 _profiles.value = profiles
87 }
89 var savedProfile: ServerProfile? = null
90 private set
92 /** When non-null, [save] throws this exception instead of persisting. */
93 var saveException: Exception? = null
95 override suspend fun resolve(profile: ServerProfile): ServerProfile = profile
97 override suspend fun save(profile: ServerProfile) {
98 saveException?.let { throw it }
99 savedProfile = profile
100 val current = _profiles.value.toMutableList()
101 current.removeAll { it.id == profile.id }
102 current.add(profile)
103 _profiles.value = current
104 }
106 override suspend fun delete(id: String) {
107 _profiles.value = _profiles.value.filterNot { it.id == id }
108 }
111// ---------------------------------------------------------------------------
112// FakeOpencodeApiClient
113// ---------------------------------------------------------------------------
115class FakeOpencodeApiClient : OpencodeApiClient() {
116 var pingThrows: Throwable? = null
117 var sessions: List<Session> = emptyList()
118 var createdSession: Session = Session(id = "new-session", title = "New")
119 var deletedSessions: List<String> = emptyList()
120 var messages: List<MessageWithParts> = emptyList()
121 var providersResponse: ProvidersResponse = ProvidersResponse()
122 var agentsList: List<Agent> = emptyList()
123 var commandsList: List<Command> = emptyList()
124 var directoryListing: List<FileNode> = emptyList()
125 var fileSearchResults: List<String> = emptyList()
126 var fileStatusEntries: List<FileStatusEntry> = emptyList()
127 var fileContent: FileContent = FileContent(content = "")
128 var projectsList: List<Project> = emptyList()
129 var pathInfo: PathInfo = PathInfo()
130 var lastCreatedDirectory: String? = null
131 private set
132 var sendPromptThrows: Throwable? = null
133 var respondPermissionThrows: Throwable? = null
134 var cacheInvalidated = false
135 var providersCacheInvalidated = false
136 private set
137 var agentsCacheInvalidated = false
138 private set
139 var commandsCacheInvalidated = false
140 private set
142 var sendPromptCalls: List<Triple<String, String, ModelRef?>> = emptyList()
143 private set
144 var runCommandCalls: List<Triple<String, String, String?>> = emptyList()
145 private set
146 var respondPermissionCalls: List<Triple<String, String, PermissionResponse>> = emptyList()
147 private set
149 override suspend fun ping() {
150 pingThrows?.let { throw it }
151 }
153 override suspend fun listSessions(): List<Session> = sessions
155 override suspend fun createSession(title: String?, directory: String?): Session {
156 lastCreatedDirectory = directory
157 return createdSession
158 }
160 override suspend fun listProjects(): List<Project> = projectsList
161 override suspend fun currentPath(): PathInfo = pathInfo
163 override suspend fun updateSession(id: String, title: String): Session {
164 return sessions.firstOrNull { it.id == id }?.copy(title = title) ?: Session(id = id, title = title)
165 }
167 override suspend fun deleteSession(id: String) {
168 deletedSessions = deletedSessions + id
169 }
171 var listMessagesCalls: List<String> = emptyList()
172 private set
174 override suspend fun listMessages(sessionId: String): List<MessageWithParts> {
175 listMessagesCalls = listMessagesCalls + sessionId
176 return messages
177 }
179 /** Idempotency keys seen across sendPrompt calls, in order, so tests can assert a
180 * re-send reuses the same key. */
181 var sendPromptKeys: List<String?> = emptyList()
182 private set
184 override suspend fun sendPrompt(
185 sessionId: String,
186 text: String,
187 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
188 model: ModelRef?,
189 agent: String?,
190 idempotencyKey: String?,
191 ): MessageWithParts {
192 sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model)
193 sendPromptKeys = sendPromptKeys + idempotencyKey
194 sendPromptThrows?.let { throw it }
195 return MessageWithParts(
196 info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId),
197 parts = emptyList(),
198 )
199 }
201 override suspend fun abort(sessionId: String) { /* no-op */ }
203 override suspend fun runCommand(
204 sessionId: String,
205 command: String,
206 arguments: String,
207 agent: String?,
208 ): MessageWithParts {
209 runCommandCalls = runCommandCalls + Triple(sessionId, command, agent)
210 return MessageWithParts(
211 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-${runCommandCalls.size}", sessionID = sessionId),
212 parts = emptyList(),
213 )
214 }
216 override suspend fun providers(): ProvidersResponse = providersResponse
217 override suspend fun agents(): List<Agent> = agentsList
218 override suspend fun commands(): List<Command> = commandsList
220 override suspend fun invalidateCache() { cacheInvalidated = true }
221 override suspend fun invalidateProvidersCache() { providersCacheInvalidated = true }
222 override suspend fun invalidateAgentsCache() { agentsCacheInvalidated = true }
223 override suspend fun invalidateCommandsCache() { commandsCacheInvalidated = true }
225 override suspend fun respondPermission(
226 sessionId: String,
227 permissionId: String,
228 response: PermissionResponse,
229 ): String {
230 respondPermissionCalls = respondPermissionCalls + Triple(sessionId, permissionId, response)
231 respondPermissionThrows?.let { throw it }
232 return ""
233 }
235 override suspend fun findFiles(query: String): List<String> = fileSearchResults
236 override suspend fun listDirectory(path: String): List<FileNode> = directoryListing
237 override suspend fun readFile(path: String): FileContent = fileContent
238 override suspend fun fileStatus(): List<FileStatusEntry> = fileStatusEntries
241// ---------------------------------------------------------------------------
242// FakeEventStreamClient
243// ---------------------------------------------------------------------------
245class FakeEventStreamClient : EventStreamClient() {
246 val fakeState = MutableStateFlow(EventStreamClient.ConnectionState.Connected)
247 override val state: StateFlow<EventStreamClient.ConnectionState> = fakeState.asStateFlow()
249 val fakeEvents = MutableSharedFlow<BusEvent>(extraBufferCapacity = NetworkConfig.sseEventBufferCapacity)
250 override val events: kotlinx.coroutines.flow.SharedFlow<BusEvent> = fakeEvents.asSharedFlow()
252 override fun triggerReconnect() { /* no-op */ }
255// ---------------------------------------------------------------------------
256// FakeSessionRepository
257// ---------------------------------------------------------------------------
259class FakeSessionRepository(
260 private val api: FakeOpencodeApiClient,
261 private val eventStream: FakeEventStreamClient,
262) : SessionRepository(api, eventStream) {
263 var sessions: List<Session> = emptyList()
264 var createdSession: Session = Session(id = "new-session", title = "New")
265 var messages: List<MessageWithParts> = emptyList()
266 var sendPromptThrows: Throwable? = null
267 var listSessionsThrows: Throwable? = null
268 /** Optional gate that [listSessions] awaits before returning, so tests can hold a refresh in flight. */
269 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
270 var abortCalls: List<String> = emptyList()
271 private set
272 var lastCreatedDirectory: String? = null
273 private set
275 /** When non-null, [observeMessages] returns this flow instead of [messages]. */
276 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
278 override suspend fun listSessions(): List<Session> {
279 listSessionsGate?.await()
280 listSessionsThrows?.let { throw it }
281 return sessions
282 }
283 override suspend fun createSession(title: String?, directory: String?): Session {
284 lastCreatedDirectory = directory
285 return createdSession
286 }
287 override suspend fun deleteSession(id: String) { /* no-op */ }
288 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
290 override suspend fun sendPrompt(
291 sessionId: String,
292 text: String,
293 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
294 model: ModelRef?,
295 agent: String?,
296 idempotencyKey: String?,
297 ): MessageWithParts {
298 sendPromptThrows?.let { throw it }
299 return MessageWithParts(
300 info = soy.iko.opencode.data.model.UserMessage(id = "msg-1", sessionID = sessionId),
301 parts = emptyList(),
302 )
303 }
305 override suspend fun runCommand(
306 sessionId: String,
307 command: String,
308 arguments: String,
309 agent: String?,
310 ): MessageWithParts = MessageWithParts(
311 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-1", sessionID = sessionId),
312 parts = emptyList(),
313 )
315 override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> =
316 observeMessagesOverride ?: flowOf(messages)
319// ---------------------------------------------------------------------------
320// FakeOpencodeConnection
321// ---------------------------------------------------------------------------
323class FakeOpencodeConnection(
324 override val api: FakeOpencodeApiClient,
325 override val events: FakeEventStreamClient,
326 override val repository: FakeSessionRepository,
327 profile: ServerProfile,
328) : OpencodeConnection(profile) {
329 override suspend fun close() { /* no-op */ }
332// ---------------------------------------------------------------------------
333// FakeSessionPrefsStore
334// ---------------------------------------------------------------------------
336class FakeSessionPrefsStore : SessionPrefsStore() {
337 private val _pinned = MutableStateFlow<Set<String>>(emptySet())
338 private val _archived = MutableStateFlow<Set<String>>(emptySet())
340 override val pinned: Flow<Set<String>> = _pinned.asStateFlow()
341 override val archived: Flow<Set<String>> = _archived.asStateFlow()
343 override suspend fun setPinned(sessionId: String, pinned: Boolean) {
344 _pinned.value = if (pinned) _pinned.value + sessionId else _pinned.value - sessionId
345 }
347 override suspend fun setArchived(sessionId: String, archived: Boolean) {
348 _archived.value = if (archived) _archived.value + sessionId else _archived.value - sessionId
349 }
351 override suspend fun forget(sessionId: String) {
352 _pinned.value = _pinned.value - sessionId
353 _archived.value = _archived.value - sessionId
354 }
357// ---------------------------------------------------------------------------
358// FakeAttachmentDraftStore / FakeMessageCacheStore
359// ---------------------------------------------------------------------------
361class FakeAttachmentDraftStore : AttachmentDraftStore() {
362 private val store = mutableMapOf<String, List<PersistedAttachment>>()
364 override suspend fun load(sessionId: String): List<PersistedAttachment> = store[sessionId].orEmpty()
366 override suspend fun save(sessionId: String, attachments: List<PersistedAttachment>) {
367 if (attachments.isEmpty()) store.remove(sessionId) else store[sessionId] = attachments
368 }
370 override suspend fun remove(sessionId: String) { store.remove(sessionId) }
373class FakeMessageCacheStore : MessageCacheStore() {
374 private val store = mutableMapOf<String, List<MessageWithParts>>()
376 override suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = store[sessionId].orEmpty()
378 override suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>) {
379 if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages
380 }
382 override suspend fun remove(profileId: String, sessionId: String) { store.remove(sessionId) }
385// ---------------------------------------------------------------------------
386// FakeAppContainer
387// ---------------------------------------------------------------------------
389class FakeAppContainer : AppContainer() {
390 val fakeDraftStore = FakeDraftStore()
391 val fakeProfileStore = FakeProfileStore()
392 val fakeSessionPrefsStore = FakeSessionPrefsStore()
393 val fakeAttachmentDraftStore = FakeAttachmentDraftStore()
394 val fakeMessageCacheStore = FakeMessageCacheStore()
395 // In-memory search history (no Context / disk).
396 val fakeSearchHistoryStore = object : soy.iko.opencode.data.repo.SearchHistoryStore() {}
397 val fakeRecentModelsStore = object : soy.iko.opencode.data.repo.RecentModelsStore() {}
398 // In-memory outbox (no Context / disk); exercises the same reduce logic the app uses.
399 val fakeOutboxStore = object : OutboxStore() {}
400 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
401 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
403 override val draftStore: DraftStore = fakeDraftStore
404 override val profileStore: ProfileStore = fakeProfileStore
405 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
406 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
407 override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
408 override val searchHistoryStore: soy.iko.opencode.data.repo.SearchHistoryStore = fakeSearchHistoryStore
409 override val recentModelsStore: soy.iko.opencode.data.repo.RecentModelsStore = fakeRecentModelsStore
410 override val outboxStore: OutboxStore = fakeOutboxStore
411 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
412 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
414 var connectResult: OpencodeConnection? = null
415 /** When non-null, [connect] throws this instead of returning a connection. */
416 var connectException: Throwable? = null
417 var connectCalls: List<ServerProfile> = emptyList()
418 private set
419 var disconnectCalls = 0
420 private set
422 /** When non-null, [probeServer] returns this instead of building a real client. */
423 var probeResult: ProbeResult? = null
424 var probeCalls: List<String> = emptyList()
425 private set
427 /** When non-null, [probeWithCredentials] returns this directly. */
428 var probeWithCredentialsResult: Boolean? = null
429 var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
430 private set
432 override fun clearUnread(id: String) {
433 fakeUnread.update { it - id }
434 }
436 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
438 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
440 override fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
441 "test-error: ${t.message ?: t::class.simpleName}"
443 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
444 connectCalls = connectCalls + profile
445 connectException?.let { throw it }
446 val conn = connectResult ?: run {
447 val api = FakeOpencodeApiClient()
448 val events = FakeEventStreamClient()
449 val repo = FakeSessionRepository(api, events)
450 FakeOpencodeConnection(api, events, repo, profile)
451 }
452 fakeActiveConnection.value = conn
453 return conn
454 }
456 override suspend fun disconnect() {
457 disconnectCalls++
458 fakeActiveConnection.value = null
459 }
461 override suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult {
462 probeCalls = probeCalls + baseUrl
463 return probeResult ?: ProbeResult.Reachable
464 }
466 override suspend fun probeWithCredentials(
467 baseUrl: String,
468 username: String,
469 password: String,
470 requireHttps: Boolean,
471 certPin: String?,
472 ): Boolean {
473 probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
474 return probeWithCredentialsResult ?: true
475 }
477 fun setActiveConnection(conn: OpencodeConnection?) {
478 fakeActiveConnection.value = conn
479 }