git.iko.soy/opencode-android
on 56dd3f3 help sign in
486 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 fileStatusThrows: Throwable? = null
128 var fileContent: FileContent = FileContent(content = "")
129 var projectsList: List<Project> = emptyList()
130 var pathInfo: PathInfo = PathInfo()
131 var lastCreatedDirectory: String? = null
132 private set
133 var sendPromptThrows: Throwable? = null
134 var respondPermissionThrows: Throwable? = null
135 var cacheInvalidated = false
136 var providersCacheInvalidated = false
137 private set
138 var agentsCacheInvalidated = false
139 private set
140 var commandsCacheInvalidated = false
141 private set
143 var sendPromptCalls: List<Triple<String, String, ModelRef?>> = emptyList()
144 private set
145 var runCommandCalls: List<Triple<String, String, String?>> = emptyList()
146 private set
147 var respondPermissionCalls: List<Triple<String, String, PermissionResponse>> = emptyList()
148 private set
150 override suspend fun ping() {
151 pingThrows?.let { throw it }
152 }
154 override suspend fun listSessions(): List<Session> = sessions
156 override suspend fun createSession(title: String?, directory: String?): Session {
157 lastCreatedDirectory = directory
158 return createdSession
159 }
161 override suspend fun listProjects(): List<Project> = projectsList
162 override suspend fun currentPath(): PathInfo = pathInfo
164 override suspend fun updateSession(id: String, title: String): Session {
165 return sessions.firstOrNull { it.id == id }?.copy(title = title) ?: Session(id = id, title = title)
166 }
168 override suspend fun deleteSession(id: String) {
169 deletedSessions = deletedSessions + id
170 }
172 var listMessagesCalls: List<String> = emptyList()
173 private set
175 override suspend fun listMessages(sessionId: String): List<MessageWithParts> {
176 listMessagesCalls = listMessagesCalls + sessionId
177 return messages
178 }
180 /** Idempotency keys seen across sendPrompt calls, in order, so tests can assert a
181 * re-send reuses the same key. */
182 var sendPromptKeys: List<String?> = emptyList()
183 private set
185 override suspend fun sendPrompt(
186 sessionId: String,
187 text: String,
188 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
189 model: ModelRef?,
190 agent: String?,
191 idempotencyKey: String?,
192 ): MessageWithParts {
193 sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model)
194 sendPromptKeys = sendPromptKeys + idempotencyKey
195 sendPromptThrows?.let { throw it }
196 return MessageWithParts(
197 info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId),
198 parts = emptyList(),
199 )
200 }
202 override suspend fun abort(sessionId: String) { /* no-op */ }
204 override suspend fun runCommand(
205 sessionId: String,
206 command: String,
207 arguments: String,
208 agent: String?,
209 ): MessageWithParts {
210 runCommandCalls = runCommandCalls + Triple(sessionId, command, agent)
211 return MessageWithParts(
212 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-${runCommandCalls.size}", sessionID = sessionId),
213 parts = emptyList(),
214 )
215 }
217 override suspend fun providers(): ProvidersResponse = providersResponse
218 override suspend fun agents(): List<Agent> = agentsList
219 override suspend fun commands(): List<Command> = commandsList
221 override suspend fun invalidateCache() { cacheInvalidated = true }
222 override suspend fun invalidateProvidersCache() { providersCacheInvalidated = true }
223 override suspend fun invalidateAgentsCache() { agentsCacheInvalidated = true }
224 override suspend fun invalidateCommandsCache() { commandsCacheInvalidated = true }
226 override suspend fun respondPermission(
227 sessionId: String,
228 permissionId: String,
229 response: PermissionResponse,
230 ): String {
231 respondPermissionCalls = respondPermissionCalls + Triple(sessionId, permissionId, response)
232 respondPermissionThrows?.let { throw it }
233 return ""
234 }
236 override suspend fun findFiles(query: String): List<String> = fileSearchResults
237 override suspend fun listDirectory(path: String): List<FileNode> = directoryListing
238 override suspend fun readFile(path: String): FileContent = fileContent
239 override suspend fun fileStatus(): List<FileStatusEntry> {
240 fileStatusThrows?.let { throw it }
241 return fileStatusEntries
242 }
245// ---------------------------------------------------------------------------
246// FakeEventStreamClient
247// ---------------------------------------------------------------------------
249class FakeEventStreamClient : EventStreamClient() {
250 val fakeState = MutableStateFlow(EventStreamClient.ConnectionState.Connected)
251 override val state: StateFlow<EventStreamClient.ConnectionState> = fakeState.asStateFlow()
253 val fakeEvents = MutableSharedFlow<BusEvent>(extraBufferCapacity = NetworkConfig.sseEventBufferCapacity)
254 override val events: kotlinx.coroutines.flow.SharedFlow<BusEvent> = fakeEvents.asSharedFlow()
256 override fun triggerReconnect() { /* no-op */ }
259// ---------------------------------------------------------------------------
260// FakeSessionRepository
261// ---------------------------------------------------------------------------
263class FakeSessionRepository(
264 private val api: FakeOpencodeApiClient,
265 private val eventStream: FakeEventStreamClient,
266) : SessionRepository(api, eventStream) {
267 var sessions: List<Session> = emptyList()
268 var createdSession: Session = Session(id = "new-session", title = "New")
269 var messages: List<MessageWithParts> = emptyList()
270 var sendPromptThrows: Throwable? = null
271 var listSessionsThrows: Throwable? = null
272 /** Optional gate that [listSessions] awaits before returning, so tests can hold a refresh in flight. */
273 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
274 var abortCalls: List<String> = emptyList()
275 private set
276 var deleteCalls: List<String> = emptyList()
277 private set
278 var lastCreatedDirectory: String? = null
279 private set
281 /** When non-null, [observeMessages] returns this flow instead of [messages]. */
282 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
284 override suspend fun listSessions(): List<Session> {
285 listSessionsGate?.await()
286 listSessionsThrows?.let { throw it }
287 return sessions
288 }
289 override suspend fun createSession(title: String?, directory: String?): Session {
290 lastCreatedDirectory = directory
291 return createdSession
292 }
293 override suspend fun deleteSession(id: String) { deleteCalls = deleteCalls + id }
294 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
296 override suspend fun sendPrompt(
297 sessionId: String,
298 text: String,
299 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
300 model: ModelRef?,
301 agent: String?,
302 idempotencyKey: String?,
303 ): MessageWithParts {
304 sendPromptThrows?.let { throw it }
305 return MessageWithParts(
306 info = soy.iko.opencode.data.model.UserMessage(id = "msg-1", sessionID = sessionId),
307 parts = emptyList(),
308 )
309 }
311 override suspend fun runCommand(
312 sessionId: String,
313 command: String,
314 arguments: String,
315 agent: String?,
316 ): MessageWithParts = MessageWithParts(
317 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-1", sessionID = sessionId),
318 parts = emptyList(),
319 )
321 override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> =
322 observeMessagesOverride ?: flowOf(messages)
325// ---------------------------------------------------------------------------
326// FakeOpencodeConnection
327// ---------------------------------------------------------------------------
329class FakeOpencodeConnection(
330 override val api: FakeOpencodeApiClient,
331 override val events: FakeEventStreamClient,
332 override val repository: FakeSessionRepository,
333 profile: ServerProfile,
334) : OpencodeConnection(profile) {
335 override suspend fun close() { /* no-op */ }
338// ---------------------------------------------------------------------------
339// FakeSessionPrefsStore
340// ---------------------------------------------------------------------------
342class FakeSessionPrefsStore : SessionPrefsStore() {
343 private val _pinned = MutableStateFlow<Set<String>>(emptySet())
344 private val _archived = MutableStateFlow<Set<String>>(emptySet())
346 override val pinned: Flow<Set<String>> = _pinned.asStateFlow()
347 override val archived: Flow<Set<String>> = _archived.asStateFlow()
349 override suspend fun setPinned(sessionId: String, pinned: Boolean) {
350 _pinned.value = if (pinned) _pinned.value + sessionId else _pinned.value - sessionId
351 }
353 override suspend fun setArchived(sessionId: String, archived: Boolean) {
354 _archived.value = if (archived) _archived.value + sessionId else _archived.value - sessionId
355 }
357 override suspend fun forget(sessionId: String) {
358 _pinned.value = _pinned.value - sessionId
359 _archived.value = _archived.value - sessionId
360 }
363// ---------------------------------------------------------------------------
364// FakeAttachmentDraftStore / FakeMessageCacheStore
365// ---------------------------------------------------------------------------
367class FakeAttachmentDraftStore : AttachmentDraftStore() {
368 private val store = mutableMapOf<String, List<PersistedAttachment>>()
370 override suspend fun load(sessionId: String): List<PersistedAttachment> = store[sessionId].orEmpty()
372 override suspend fun save(sessionId: String, attachments: List<PersistedAttachment>) {
373 if (attachments.isEmpty()) store.remove(sessionId) else store[sessionId] = attachments
374 }
376 override suspend fun remove(sessionId: String) { store.remove(sessionId) }
379class FakeMessageCacheStore : MessageCacheStore() {
380 private val store = mutableMapOf<String, List<MessageWithParts>>()
382 override suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = store[sessionId].orEmpty()
384 override suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>) {
385 if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages
386 }
388 override suspend fun remove(profileId: String, sessionId: String) { store.remove(sessionId) }
391// ---------------------------------------------------------------------------
392// FakeAppContainer
393// ---------------------------------------------------------------------------
395class FakeAppContainer : AppContainer() {
396 val fakeDraftStore = FakeDraftStore()
397 val fakeProfileStore = FakeProfileStore()
398 val fakeSessionPrefsStore = FakeSessionPrefsStore()
399 val fakeAttachmentDraftStore = FakeAttachmentDraftStore()
400 val fakeMessageCacheStore = FakeMessageCacheStore()
401 // In-memory search history (no Context / disk).
402 val fakeSearchHistoryStore = object : soy.iko.opencode.data.repo.SearchHistoryStore() {}
403 val fakeRecentModelsStore = object : soy.iko.opencode.data.repo.RecentModelsStore() {}
404 // In-memory outbox (no Context / disk); exercises the same reduce logic the app uses.
405 val fakeOutboxStore = object : OutboxStore() {}
406 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
407 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
409 override val draftStore: DraftStore = fakeDraftStore
410 override val profileStore: ProfileStore = fakeProfileStore
411 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
412 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
413 override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
414 override val searchHistoryStore: soy.iko.opencode.data.repo.SearchHistoryStore = fakeSearchHistoryStore
415 override val recentModelsStore: soy.iko.opencode.data.repo.RecentModelsStore = fakeRecentModelsStore
416 override val outboxStore: OutboxStore = fakeOutboxStore
417 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
418 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
420 var connectResult: OpencodeConnection? = null
421 /** When non-null, [connect] throws this instead of returning a connection. */
422 var connectException: Throwable? = null
423 var connectCalls: List<ServerProfile> = emptyList()
424 private set
425 var disconnectCalls = 0
426 private set
428 /** When non-null, [probeServer] returns this instead of building a real client. */
429 var probeResult: ProbeResult? = null
430 var probeCalls: List<String> = emptyList()
431 private set
433 /** When non-null, [probeWithCredentials] returns this directly. */
434 var probeWithCredentialsResult: Boolean? = null
435 var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
436 private set
438 override fun clearUnread(id: String) {
439 fakeUnread.update { it - id }
440 }
442 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
444 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
446 override fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
447 "test-error: ${t.message ?: t::class.simpleName}"
449 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
450 connectCalls = connectCalls + profile
451 connectException?.let { throw it }
452 val conn = connectResult ?: run {
453 val api = FakeOpencodeApiClient()
454 val events = FakeEventStreamClient()
455 val repo = FakeSessionRepository(api, events)
456 FakeOpencodeConnection(api, events, repo, profile)
457 }
458 fakeActiveConnection.value = conn
459 return conn
460 }
462 override suspend fun disconnect() {
463 disconnectCalls++
464 fakeActiveConnection.value = null
465 }
467 override suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult {
468 probeCalls = probeCalls + baseUrl
469 return probeResult ?: ProbeResult.Reachable
470 }
472 override suspend fun probeWithCredentials(
473 baseUrl: String,
474 username: String,
475 password: String,
476 requireHttps: Boolean,
477 certPin: String?,
478 ): Boolean {
479 probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
480 return probeWithCredentialsResult ?: true
481 }
483 fun setActiveConnection(conn: OpencodeConnection?) {
484 fakeActiveConnection.value = conn
485 }