git.iko.soy/opencode-android
on d29e57b help sign in
370 lines 15 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.PermissionResponse
21import soy.iko.opencode.data.model.ProvidersResponse
22import soy.iko.opencode.data.model.ServerProfile
23import soy.iko.opencode.data.model.Session
24import soy.iko.opencode.data.network.EventStreamClient
25import soy.iko.opencode.data.network.NetworkConfig
26import soy.iko.opencode.data.network.OpencodeApiClient
27import soy.iko.opencode.data.repo.DraftStore
28import soy.iko.opencode.data.repo.ProfileStore
29import soy.iko.opencode.data.repo.SessionRepository
30import soy.iko.opencode.di.AppContainer
31import soy.iko.opencode.di.OpencodeConnection
32import soy.iko.opencode.di.ProbeResult
34// ---------------------------------------------------------------------------
35// FakeDraftStore
36// ---------------------------------------------------------------------------
38class FakeDraftStore : DraftStore() {
39 private val store = mutableMapOf<String, String>()
40 private val _ready = MutableStateFlow(true)
41 override val ready: StateFlow<Boolean> = _ready.asStateFlow()
43 var lastFlushed: Pair<String, String>? = null
44 private set
46 override fun get(sessionId: String): String = store[sessionId].orEmpty()
48 override suspend fun set(sessionId: String, text: String) {
49 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
50 }
52 override fun setImmediate(sessionId: String, text: String) {
53 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
54 }
56 override suspend fun remove(sessionId: String) {
57 store.remove(sessionId)
58 }
60 override fun flushDraft(sessionId: String, text: String) {
61 lastFlushed = sessionId to text
62 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
63 }
65 override fun shutdown() { /* no-op */ }
68// ---------------------------------------------------------------------------
69// FakeProfileStore
70// ---------------------------------------------------------------------------
72class FakeProfileStore : ProfileStore() {
73 private val _profiles = MutableStateFlow<List<ServerProfile>>(emptyList())
75 override val profiles: Flow<List<ServerProfile>> = _profiles.asStateFlow()
77 fun setProfiles(profiles: List<ServerProfile>) {
78 _profiles.value = profiles
79 }
81 var savedProfile: ServerProfile? = null
82 private set
84 /** When non-null, [save] throws this exception instead of persisting. */
85 var saveException: Exception? = null
87 override suspend fun resolve(profile: ServerProfile): ServerProfile = profile
89 override suspend fun save(profile: ServerProfile) {
90 saveException?.let { throw it }
91 savedProfile = profile
92 val current = _profiles.value.toMutableList()
93 current.removeAll { it.id == profile.id }
94 current.add(profile)
95 _profiles.value = current
96 }
98 override suspend fun delete(id: String) {
99 _profiles.value = _profiles.value.filterNot { it.id == id }
100 }
103// ---------------------------------------------------------------------------
104// FakeOpencodeApiClient
105// ---------------------------------------------------------------------------
107class FakeOpencodeApiClient : OpencodeApiClient() {
108 var pingThrows: Throwable? = null
109 var sessions: List<Session> = emptyList()
110 var createdSession: Session = Session(id = "new-session", title = "New")
111 var deletedSessions: List<String> = emptyList()
112 var messages: List<MessageWithParts> = emptyList()
113 var providersResponse: ProvidersResponse = ProvidersResponse()
114 var agentsList: List<Agent> = emptyList()
115 var commandsList: List<Command> = emptyList()
116 var directoryListing: List<FileNode> = emptyList()
117 var fileSearchResults: List<String> = emptyList()
118 var fileStatusEntries: List<FileStatusEntry> = emptyList()
119 var fileContent: FileContent = FileContent(content = "")
120 var sendPromptThrows: Throwable? = null
121 var respondPermissionThrows: Throwable? = null
122 var cacheInvalidated = false
123 var providersCacheInvalidated = false
124 private set
125 var agentsCacheInvalidated = false
126 private set
127 var commandsCacheInvalidated = false
128 private set
130 var sendPromptCalls: List<Triple<String, String, ModelRef?>> = emptyList()
131 private set
132 var runCommandCalls: List<Triple<String, String, String?>> = emptyList()
133 private set
134 var respondPermissionCalls: List<Triple<String, String, PermissionResponse>> = emptyList()
135 private set
137 override suspend fun ping() {
138 pingThrows?.let { throw it }
139 }
141 override suspend fun listSessions(): List<Session> = sessions
143 override suspend fun createSession(title: String?): Session = createdSession
145 override suspend fun updateSession(id: String, title: String): Session {
146 return sessions.firstOrNull { it.id == id }?.copy(title = title) ?: Session(id = id, title = title)
147 }
149 override suspend fun deleteSession(id: String) {
150 deletedSessions = deletedSessions + id
151 }
153 var listMessagesCalls: List<String> = emptyList()
154 private set
156 override suspend fun listMessages(sessionId: String): List<MessageWithParts> {
157 listMessagesCalls = listMessagesCalls + sessionId
158 return messages
159 }
161 override suspend fun sendPrompt(
162 sessionId: String,
163 text: String,
164 model: ModelRef?,
165 agent: String?,
166 ): MessageWithParts {
167 sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model)
168 sendPromptThrows?.let { throw it }
169 return MessageWithParts(
170 info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId),
171 parts = emptyList(),
172 )
173 }
175 override suspend fun abort(sessionId: String) { /* no-op */ }
177 override suspend fun runCommand(
178 sessionId: String,
179 command: String,
180 arguments: String,
181 agent: String?,
182 ): MessageWithParts {
183 runCommandCalls = runCommandCalls + Triple(sessionId, command, agent)
184 return MessageWithParts(
185 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-${runCommandCalls.size}", sessionID = sessionId),
186 parts = emptyList(),
187 )
188 }
190 override suspend fun providers(): ProvidersResponse = providersResponse
191 override suspend fun agents(): List<Agent> = agentsList
192 override suspend fun commands(): List<Command> = commandsList
194 override suspend fun invalidateCache() { cacheInvalidated = true }
195 override suspend fun invalidateProvidersCache() { providersCacheInvalidated = true }
196 override suspend fun invalidateAgentsCache() { agentsCacheInvalidated = true }
197 override suspend fun invalidateCommandsCache() { commandsCacheInvalidated = true }
199 override suspend fun respondPermission(
200 sessionId: String,
201 permissionId: String,
202 response: PermissionResponse,
203 ): String {
204 respondPermissionCalls = respondPermissionCalls + Triple(sessionId, permissionId, response)
205 respondPermissionThrows?.let { throw it }
206 return ""
207 }
209 override suspend fun findFiles(query: String): List<String> = fileSearchResults
210 override suspend fun listDirectory(path: String): List<FileNode> = directoryListing
211 override suspend fun readFile(path: String): FileContent = fileContent
212 override suspend fun fileStatus(): List<FileStatusEntry> = fileStatusEntries
215// ---------------------------------------------------------------------------
216// FakeEventStreamClient
217// ---------------------------------------------------------------------------
219class FakeEventStreamClient : EventStreamClient() {
220 val fakeState = MutableStateFlow(EventStreamClient.ConnectionState.Connected)
221 override val state: StateFlow<EventStreamClient.ConnectionState> = fakeState.asStateFlow()
223 val fakeEvents = MutableSharedFlow<BusEvent>(extraBufferCapacity = NetworkConfig.sseEventBufferCapacity)
224 override val events: kotlinx.coroutines.flow.SharedFlow<BusEvent> = fakeEvents.asSharedFlow()
226 override fun triggerReconnect() { /* no-op */ }
229// ---------------------------------------------------------------------------
230// FakeSessionRepository
231// ---------------------------------------------------------------------------
233class FakeSessionRepository(
234 private val api: FakeOpencodeApiClient,
235 private val eventStream: FakeEventStreamClient,
236) : SessionRepository(api, eventStream) {
237 var sessions: List<Session> = emptyList()
238 var createdSession: Session = Session(id = "new-session", title = "New")
239 var messages: List<MessageWithParts> = emptyList()
240 var sendPromptThrows: Throwable? = null
241 var listSessionsThrows: Throwable? = null
242 /** Optional gate that [listSessions] awaits before returning, so tests can hold a refresh in flight. */
243 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
244 var abortCalls: List<String> = emptyList()
245 private set
247 /** When non-null, [observeMessages] returns this flow instead of [messages]. */
248 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
250 override suspend fun listSessions(): List<Session> {
251 listSessionsGate?.await()
252 listSessionsThrows?.let { throw it }
253 return sessions
254 }
255 override suspend fun createSession(title: String?): Session = createdSession
256 override suspend fun deleteSession(id: String) { /* no-op */ }
257 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
259 override suspend fun sendPrompt(
260 sessionId: String,
261 text: String,
262 model: ModelRef?,
263 agent: String?,
264 ): MessageWithParts {
265 sendPromptThrows?.let { throw it }
266 return MessageWithParts(
267 info = soy.iko.opencode.data.model.UserMessage(id = "msg-1", sessionID = sessionId),
268 parts = emptyList(),
269 )
270 }
272 override suspend fun runCommand(
273 sessionId: String,
274 command: String,
275 arguments: String,
276 agent: String?,
277 ): MessageWithParts = MessageWithParts(
278 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-1", sessionID = sessionId),
279 parts = emptyList(),
280 )
282 override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> =
283 observeMessagesOverride ?: flowOf(messages)
286// ---------------------------------------------------------------------------
287// FakeOpencodeConnection
288// ---------------------------------------------------------------------------
290class FakeOpencodeConnection(
291 override val api: FakeOpencodeApiClient,
292 override val events: FakeEventStreamClient,
293 override val repository: FakeSessionRepository,
294 profile: ServerProfile,
295) : OpencodeConnection(profile) {
296 override suspend fun close() { /* no-op */ }
299// ---------------------------------------------------------------------------
300// FakeAppContainer
301// ---------------------------------------------------------------------------
303class FakeAppContainer : AppContainer() {
304 val fakeDraftStore = FakeDraftStore()
305 val fakeProfileStore = FakeProfileStore()
306 val fakeUnread = MutableStateFlow<Set<String>>(emptySet())
307 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
309 override val draftStore: DraftStore = fakeDraftStore
310 override val profileStore: ProfileStore = fakeProfileStore
311 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
312 override val unread: StateFlow<Set<String>> = fakeUnread.asStateFlow()
314 var connectResult: OpencodeConnection? = null
315 /** When non-null, [connect] throws this instead of returning a connection. */
316 var connectException: Throwable? = null
317 var connectCalls: List<ServerProfile> = emptyList()
318 private set
319 var disconnectCalls = 0
320 private set
322 /** When non-null, [probeServer] returns this instead of building a real client. */
323 var probeResult: ProbeResult? = null
324 var probeCalls: List<String> = emptyList()
325 private set
327 /** When non-null, [probeWithCredentials] returns this directly. */
328 var probeWithCredentialsResult: Boolean? = null
329 var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
330 private set
332 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
334 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
336 override fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
337 "test-error: ${t.message ?: t::class.simpleName}"
339 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
340 connectCalls = connectCalls + profile
341 connectException?.let { throw it }
342 val conn = connectResult ?: run {
343 val api = FakeOpencodeApiClient()
344 val events = FakeEventStreamClient()
345 val repo = FakeSessionRepository(api, events)
346 FakeOpencodeConnection(api, events, repo, profile)
347 }
348 fakeActiveConnection.value = conn
349 return conn
350 }
352 override suspend fun disconnect() {
353 disconnectCalls++
354 fakeActiveConnection.value = null
355 }
357 override suspend fun probeServer(baseUrl: String): ProbeResult {
358 probeCalls = probeCalls + baseUrl
359 return probeResult ?: ProbeResult.Reachable
360 }
362 override suspend fun probeWithCredentials(baseUrl: String, username: String, password: String): Boolean {
363 probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
364 return probeWithCredentialsResult ?: true
365 }
367 fun setActiveConnection(conn: OpencodeConnection?) {
368 fakeActiveConnection.value = conn
369 }