git.iko.soy/opencode-android
on d4cde6a help sign in
319 lines 12 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
33// ---------------------------------------------------------------------------
34// FakeDraftStore
35// ---------------------------------------------------------------------------
37class FakeDraftStore : DraftStore() {
38 private val store = mutableMapOf<String, String>()
39 private val _ready = MutableStateFlow(true)
40 override val ready: StateFlow<Boolean> = _ready.asStateFlow()
42 var lastFlushed: Pair<String, String>? = null
43 private set
45 override fun get(sessionId: String): String = store[sessionId].orEmpty()
47 override suspend fun set(sessionId: String, text: String) {
48 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
49 }
51 override fun setImmediate(sessionId: String, text: String) {
52 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
53 }
55 override suspend fun remove(sessionId: String) {
56 store.remove(sessionId)
57 }
59 override fun flushDraft(sessionId: String, text: String) {
60 lastFlushed = sessionId to text
61 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
62 }
64 override fun shutdown() { /* no-op */ }
67// ---------------------------------------------------------------------------
68// FakeProfileStore
69// ---------------------------------------------------------------------------
71class FakeProfileStore : ProfileStore() {
72 private val _profiles = MutableStateFlow<List<ServerProfile>>(emptyList())
74 override val profiles: Flow<List<ServerProfile>> = _profiles.asStateFlow()
76 fun setProfiles(profiles: List<ServerProfile>) {
77 _profiles.value = profiles
78 }
80 var savedProfile: ServerProfile? = null
81 private set
83 /** When non-null, [save] throws this exception instead of persisting. */
84 var saveException: Exception? = null
86 override suspend fun resolve(profile: ServerProfile): ServerProfile = profile
88 override suspend fun save(profile: ServerProfile) {
89 saveException?.let { throw it }
90 savedProfile = profile
91 val current = _profiles.value.toMutableList()
92 current.removeAll { it.id == profile.id }
93 current.add(profile)
94 _profiles.value = current
95 }
97 override suspend fun delete(id: String) {
98 _profiles.value = _profiles.value.filterNot { it.id == id }
99 }
102// ---------------------------------------------------------------------------
103// FakeOpencodeApiClient
104// ---------------------------------------------------------------------------
106class FakeOpencodeApiClient : OpencodeApiClient() {
107 var pingThrows: Throwable? = null
108 var sessions: List<Session> = emptyList()
109 var createdSession: Session = Session(id = "new-session", title = "New")
110 var deletedSessions: List<String> = emptyList()
111 var messages: List<MessageWithParts> = emptyList()
112 var providersResponse: ProvidersResponse = ProvidersResponse()
113 var agentsList: List<Agent> = emptyList()
114 var commandsList: List<Command> = emptyList()
115 var directoryListing: List<FileNode> = emptyList()
116 var fileSearchResults: List<String> = emptyList()
117 var fileStatusEntries: List<FileStatusEntry> = emptyList()
118 var fileContent: FileContent = FileContent(content = "")
119 var sendPromptThrows: Throwable? = null
120 var respondPermissionThrows: Throwable? = null
121 var cacheInvalidated = false
123 var sendPromptCalls: List<Triple<String, String, ModelRef?>> = emptyList()
124 private set
125 var runCommandCalls: List<Triple<String, String, String?>> = emptyList()
126 private set
127 var respondPermissionCalls: List<Triple<String, String, PermissionResponse>> = emptyList()
128 private set
130 override suspend fun ping() {
131 pingThrows?.let { throw it }
132 }
134 override suspend fun listSessions(): List<Session> = sessions
136 override suspend fun createSession(title: String?): Session = createdSession
138 override suspend fun updateSession(id: String, title: String): Session {
139 return sessions.firstOrNull { it.id == id }?.copy(title = title) ?: Session(id = id, title = title)
140 }
142 override suspend fun deleteSession(id: String) {
143 deletedSessions = deletedSessions + id
144 }
146 override suspend fun listMessages(sessionId: String): List<MessageWithParts> = messages
148 override suspend fun sendPrompt(
149 sessionId: String,
150 text: String,
151 model: ModelRef?,
152 agent: String?,
153 ): MessageWithParts {
154 sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model)
155 sendPromptThrows?.let { throw it }
156 return MessageWithParts(
157 info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId),
158 parts = emptyList(),
159 )
160 }
162 override suspend fun abort(sessionId: String) { /* no-op */ }
164 override suspend fun runCommand(
165 sessionId: String,
166 command: String,
167 arguments: String,
168 agent: String?,
169 ): MessageWithParts {
170 runCommandCalls = runCommandCalls + Triple(sessionId, command, agent)
171 return MessageWithParts(
172 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-${runCommandCalls.size}", sessionID = sessionId),
173 parts = emptyList(),
174 )
175 }
177 override suspend fun providers(): ProvidersResponse = providersResponse
178 override suspend fun agents(): List<Agent> = agentsList
179 override suspend fun commands(): List<Command> = commandsList
181 override suspend fun invalidateCache() { cacheInvalidated = true }
183 override suspend fun respondPermission(
184 sessionId: String,
185 permissionId: String,
186 response: PermissionResponse,
187 ): String {
188 respondPermissionCalls = respondPermissionCalls + Triple(sessionId, permissionId, response)
189 respondPermissionThrows?.let { throw it }
190 return ""
191 }
193 override suspend fun findFiles(query: String): List<String> = fileSearchResults
194 override suspend fun listDirectory(path: String): List<FileNode> = directoryListing
195 override suspend fun readFile(path: String): FileContent = fileContent
196 override suspend fun fileStatus(): List<FileStatusEntry> = fileStatusEntries
199// ---------------------------------------------------------------------------
200// FakeEventStreamClient
201// ---------------------------------------------------------------------------
203class FakeEventStreamClient : EventStreamClient() {
204 val fakeState = MutableStateFlow(EventStreamClient.ConnectionState.Connected)
205 override val state: StateFlow<EventStreamClient.ConnectionState> = fakeState.asStateFlow()
207 val fakeEvents = MutableSharedFlow<BusEvent>(extraBufferCapacity = NetworkConfig.sseEventBufferCapacity)
208 override val events: kotlinx.coroutines.flow.SharedFlow<BusEvent> = fakeEvents.asSharedFlow()
210 override fun triggerReconnect() { /* no-op */ }
213// ---------------------------------------------------------------------------
214// FakeSessionRepository
215// ---------------------------------------------------------------------------
217class FakeSessionRepository(
218 private val api: FakeOpencodeApiClient,
219 private val eventStream: FakeEventStreamClient,
220) : SessionRepository(api, eventStream) {
221 var sessions: List<Session> = emptyList()
222 var createdSession: Session = Session(id = "new-session", title = "New")
223 var messages: List<MessageWithParts> = emptyList()
224 var sendPromptThrows: Throwable? = null
225 var listSessionsThrows: Throwable? = null
226 var abortCalls: List<String> = emptyList()
227 private set
229 override suspend fun listSessions(): List<Session> {
230 listSessionsThrows?.let { throw it }
231 return sessions
232 }
233 override suspend fun createSession(title: String?): Session = createdSession
234 override suspend fun deleteSession(id: String) { /* no-op */ }
235 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
237 override suspend fun sendPrompt(
238 sessionId: String,
239 text: String,
240 model: ModelRef?,
241 agent: String?,
242 ): MessageWithParts {
243 sendPromptThrows?.let { throw it }
244 return MessageWithParts(
245 info = soy.iko.opencode.data.model.UserMessage(id = "msg-1", sessionID = sessionId),
246 parts = emptyList(),
247 )
248 }
250 override suspend fun runCommand(
251 sessionId: String,
252 command: String,
253 arguments: String,
254 agent: String?,
255 ): MessageWithParts = MessageWithParts(
256 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-1", sessionID = sessionId),
257 parts = emptyList(),
258 )
260 override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = flowOf(messages)
263// ---------------------------------------------------------------------------
264// FakeOpencodeConnection
265// ---------------------------------------------------------------------------
267class FakeOpencodeConnection(
268 override val api: FakeOpencodeApiClient,
269 override val events: FakeEventStreamClient,
270 override val repository: FakeSessionRepository,
271 profile: ServerProfile,
272) : OpencodeConnection(profile) {
273 override suspend fun close() { /* no-op */ }
276// ---------------------------------------------------------------------------
277// FakeAppContainer
278// ---------------------------------------------------------------------------
280class FakeAppContainer : AppContainer() {
281 val fakeDraftStore = FakeDraftStore()
282 val fakeProfileStore = FakeProfileStore()
283 val fakeUnread = MutableStateFlow<Set<String>>(emptySet())
284 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
286 override val draftStore: DraftStore = fakeDraftStore
287 override val profileStore: ProfileStore = fakeProfileStore
288 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
289 override val unread: StateFlow<Set<String>> = fakeUnread.asStateFlow()
291 var connectResult: OpencodeConnection? = null
292 var connectCalls: List<ServerProfile> = emptyList()
293 private set
294 var disconnectCalls = 0
295 private set
297 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
299 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
301 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
302 connectCalls = connectCalls + profile
303 connectResult?.let { return it }
304 // Create a fake connection on the fly if none pre-configured
305 val api = FakeOpencodeApiClient()
306 val events = FakeEventStreamClient()
307 val repo = FakeSessionRepository(api, events)
308 return FakeOpencodeConnection(api, events, repo, profile)
309 }
311 override suspend fun disconnect() {
312 disconnectCalls++
313 fakeActiveConnection.value = null
314 }
316 fun setActiveConnection(conn: OpencodeConnection?) {
317 fakeActiveConnection.value = conn
318 }