git.iko.soy/opencode-android
on 59feb55 help sign in
363 lines 14 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 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
329 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
331 override fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
332 "test-error: ${t.message ?: t::class.simpleName}"
334 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
335 connectCalls = connectCalls + profile
336 connectException?.let { throw it }
337 // Mirror the real AppContainer: connect() publishes the new connection so
338 // collectors (SSE observer, refresh) see it immediately. This lets the
339 // switchServer failure/restore paths be exercised end-to-end.
340 val conn = connectResult ?: run {
341 val api = FakeOpencodeApiClient()
342 val events = FakeEventStreamClient()
343 val repo = FakeSessionRepository(api, events)
344 FakeOpencodeConnection(api, events, repo, profile)
345 }
346 fakeActiveConnection.value = conn
347 return conn
348 }
350 override suspend fun disconnect() {
351 disconnectCalls++
352 fakeActiveConnection.value = null
353 }
355 override suspend fun probeServer(baseUrl: String): ProbeResult {
356 probeCalls = probeCalls + baseUrl
357 return probeResult ?: ProbeResult.Reachable
358 }
360 fun setActiveConnection(conn: OpencodeConnection?) {
361 fakeActiveConnection.value = conn
362 }