git.iko.soy/opencode-android
on 598c306 help sign in
315 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 abortCalls: List<String> = emptyList()
226 private set
228 override suspend fun listSessions(): List<Session> = sessions
229 override suspend fun createSession(title: String?): Session = createdSession
230 override suspend fun deleteSession(id: String) { /* no-op */ }
231 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
233 override suspend fun sendPrompt(
234 sessionId: String,
235 text: String,
236 model: ModelRef?,
237 agent: String?,
238 ): MessageWithParts {
239 sendPromptThrows?.let { throw it }
240 return MessageWithParts(
241 info = soy.iko.opencode.data.model.UserMessage(id = "msg-1", sessionID = sessionId),
242 parts = emptyList(),
243 )
244 }
246 override suspend fun runCommand(
247 sessionId: String,
248 command: String,
249 arguments: String,
250 agent: String?,
251 ): MessageWithParts = MessageWithParts(
252 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-1", sessionID = sessionId),
253 parts = emptyList(),
254 )
256 override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = flowOf(messages)
259// ---------------------------------------------------------------------------
260// FakeOpencodeConnection
261// ---------------------------------------------------------------------------
263class FakeOpencodeConnection(
264 override val api: FakeOpencodeApiClient,
265 override val events: FakeEventStreamClient,
266 override val repository: FakeSessionRepository,
267 profile: ServerProfile,
268) : OpencodeConnection(profile) {
269 override suspend fun close() { /* no-op */ }
272// ---------------------------------------------------------------------------
273// FakeAppContainer
274// ---------------------------------------------------------------------------
276class FakeAppContainer : AppContainer() {
277 val fakeDraftStore = FakeDraftStore()
278 val fakeProfileStore = FakeProfileStore()
279 val fakeUnread = MutableStateFlow<Set<String>>(emptySet())
280 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
282 override val draftStore: DraftStore = fakeDraftStore
283 override val profileStore: ProfileStore = fakeProfileStore
284 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
285 override val unread: StateFlow<Set<String>> = fakeUnread.asStateFlow()
287 var connectResult: OpencodeConnection? = null
288 var connectCalls: List<ServerProfile> = emptyList()
289 private set
290 var disconnectCalls = 0
291 private set
293 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
295 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
297 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
298 connectCalls = connectCalls + profile
299 connectResult?.let { return it }
300 // Create a fake connection on the fly if none pre-configured
301 val api = FakeOpencodeApiClient()
302 val events = FakeEventStreamClient()
303 val repo = FakeSessionRepository(api, events)
304 return FakeOpencodeConnection(api, events, repo, profile)
305 }
307 override suspend fun disconnect() {
308 disconnectCalls++
309 fakeActiveConnection.value = null
310 }
312 fun setActiveConnection(conn: OpencodeConnection?) {
313 fakeActiveConnection.value = conn
314 }