git.iko.soy/opencode-android
on 930859e help sign in
475 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 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 outbox (no Context / disk); exercises the same reduce logic the app uses.
396 val fakeOutboxStore = object : OutboxStore() {}
397 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
398 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
400 override val draftStore: DraftStore = fakeDraftStore
401 override val profileStore: ProfileStore = fakeProfileStore
402 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
403 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
404 override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
405 override val outboxStore: OutboxStore = fakeOutboxStore
406 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
407 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
409 var connectResult: OpencodeConnection? = null
410 /** When non-null, [connect] throws this instead of returning a connection. */
411 var connectException: Throwable? = null
412 var connectCalls: List<ServerProfile> = emptyList()
413 private set
414 var disconnectCalls = 0
415 private set
417 /** When non-null, [probeServer] returns this instead of building a real client. */
418 var probeResult: ProbeResult? = null
419 var probeCalls: List<String> = emptyList()
420 private set
422 /** When non-null, [probeWithCredentials] returns this directly. */
423 var probeWithCredentialsResult: Boolean? = null
424 var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
425 private set
427 override fun clearUnread(id: String) {
428 fakeUnread.update { it - id }
429 }
431 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
433 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
435 override fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
436 "test-error: ${t.message ?: t::class.simpleName}"
438 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
439 connectCalls = connectCalls + profile
440 connectException?.let { throw it }
441 val conn = connectResult ?: run {
442 val api = FakeOpencodeApiClient()
443 val events = FakeEventStreamClient()
444 val repo = FakeSessionRepository(api, events)
445 FakeOpencodeConnection(api, events, repo, profile)
446 }
447 fakeActiveConnection.value = conn
448 return conn
449 }
451 override suspend fun disconnect() {
452 disconnectCalls++
453 fakeActiveConnection.value = null
454 }
456 override suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult {
457 probeCalls = probeCalls + baseUrl
458 return probeResult ?: ProbeResult.Reachable
459 }
461 override suspend fun probeWithCredentials(
462 baseUrl: String,
463 username: String,
464 password: String,
465 requireHttps: Boolean,
466 certPin: String?,
467 ): Boolean {
468 probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
469 return probeWithCredentialsResult ?: true
470 }
472 fun setActiveConnection(conn: OpencodeConnection?) {
473 fakeActiveConnection.value = conn
474 }