@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) package soy.iko.opencode.ui.testing import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.update import soy.iko.opencode.data.model.Agent import soy.iko.opencode.data.model.BusEvent import soy.iko.opencode.data.model.Command import soy.iko.opencode.data.model.FileContent import soy.iko.opencode.data.model.FileNode import soy.iko.opencode.data.model.FileStatusEntry import soy.iko.opencode.data.model.MessageWithParts import soy.iko.opencode.data.model.ModelRef import soy.iko.opencode.data.model.PathInfo import soy.iko.opencode.data.model.PermissionResponse import soy.iko.opencode.data.model.Project import soy.iko.opencode.data.model.ProvidersResponse import soy.iko.opencode.data.model.ServerProfile import soy.iko.opencode.data.model.Session import soy.iko.opencode.data.network.EventStreamClient import soy.iko.opencode.data.network.NetworkConfig import soy.iko.opencode.data.network.OpencodeApiClient import soy.iko.opencode.data.repo.AttachmentDraftStore import soy.iko.opencode.data.repo.DraftStore import soy.iko.opencode.data.repo.MessageCacheStore import soy.iko.opencode.data.repo.OutboxStore import soy.iko.opencode.data.repo.PersistedAttachment import soy.iko.opencode.data.repo.ProfileStore import soy.iko.opencode.data.repo.SessionPrefsStore import soy.iko.opencode.data.repo.SessionRepository import soy.iko.opencode.di.AppContainer import soy.iko.opencode.di.OpencodeConnection import soy.iko.opencode.di.ProbeResult // --------------------------------------------------------------------------- // FakeDraftStore // --------------------------------------------------------------------------- class FakeDraftStore : DraftStore() { private val store = mutableMapOf() private val _ready = MutableStateFlow(true) override val ready: StateFlow = _ready.asStateFlow() var lastFlushed: Pair? = null private set override fun get(sessionId: String): String = store[sessionId].orEmpty() override suspend fun set(sessionId: String, text: String) { if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text } override fun setImmediate(sessionId: String, text: String) { if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text } override suspend fun remove(sessionId: String) { store.remove(sessionId) } override fun flushDraft(sessionId: String, text: String) { lastFlushed = sessionId to text if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text } override fun shutdown() { /* no-op */ } } // --------------------------------------------------------------------------- // FakeProfileStore // --------------------------------------------------------------------------- class FakeProfileStore : ProfileStore() { private val _profiles = MutableStateFlow>(emptyList()) override val profiles: Flow> = _profiles.asStateFlow() fun setProfiles(profiles: List) { _profiles.value = profiles } var savedProfile: ServerProfile? = null private set /** When non-null, [save] throws this exception instead of persisting. */ var saveException: Exception? = null override suspend fun resolve(profile: ServerProfile): ServerProfile = profile override suspend fun save(profile: ServerProfile) { saveException?.let { throw it } savedProfile = profile val current = _profiles.value.toMutableList() current.removeAll { it.id == profile.id } current.add(profile) _profiles.value = current } override suspend fun delete(id: String) { _profiles.value = _profiles.value.filterNot { it.id == id } } } // --------------------------------------------------------------------------- // FakeOpencodeApiClient // --------------------------------------------------------------------------- class FakeOpencodeApiClient : OpencodeApiClient() { var pingThrows: Throwable? = null var sessions: List = emptyList() var createdSession: Session = Session(id = "new-session", title = "New") var deletedSessions: List = emptyList() var messages: List = emptyList() var providersResponse: ProvidersResponse = ProvidersResponse() var agentsList: List = emptyList() var commandsList: List = emptyList() var directoryListing: List = emptyList() var fileSearchResults: List = emptyList() var fileStatusEntries: List = emptyList() var fileStatusThrows: Throwable? = null var fileContent: FileContent = FileContent(content = "") var projectsList: List = emptyList() var pathInfo: PathInfo = PathInfo() var lastCreatedDirectory: String? = null private set var sendPromptThrows: Throwable? = null var respondPermissionThrows: Throwable? = null var cacheInvalidated = false var providersCacheInvalidated = false private set var agentsCacheInvalidated = false private set var commandsCacheInvalidated = false private set var sendPromptCalls: List> = emptyList() private set var runCommandCalls: List> = emptyList() private set var respondPermissionCalls: List> = emptyList() private set override suspend fun ping() { pingThrows?.let { throw it } } override suspend fun listSessions(): List = sessions override suspend fun createSession(title: String?, directory: String?): Session { lastCreatedDirectory = directory return createdSession } override suspend fun listProjects(): List = projectsList override suspend fun currentPath(): PathInfo = pathInfo override suspend fun updateSession(id: String, title: String): Session { return sessions.firstOrNull { it.id == id }?.copy(title = title) ?: Session(id = id, title = title) } override suspend fun deleteSession(id: String) { deletedSessions = deletedSessions + id } var listMessagesCalls: List = emptyList() private set override suspend fun listMessages(sessionId: String): List { listMessagesCalls = listMessagesCalls + sessionId return messages } /** Idempotency keys seen across sendPrompt calls, in order, so tests can assert a * re-send reuses the same key. */ var sendPromptKeys: List = emptyList() private set override suspend fun sendPrompt( sessionId: String, text: String, attachments: List, model: ModelRef?, agent: String?, idempotencyKey: String?, ): MessageWithParts { sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model) sendPromptKeys = sendPromptKeys + idempotencyKey sendPromptThrows?.let { throw it } return MessageWithParts( info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId), parts = emptyList(), ) } override suspend fun abort(sessionId: String) { /* no-op */ } override suspend fun runCommand( sessionId: String, command: String, arguments: String, agent: String?, ): MessageWithParts { runCommandCalls = runCommandCalls + Triple(sessionId, command, agent) return MessageWithParts( info = soy.iko.opencode.data.model.UserMessage(id = "cmd-${runCommandCalls.size}", sessionID = sessionId), parts = emptyList(), ) } override suspend fun providers(): ProvidersResponse = providersResponse override suspend fun agents(): List = agentsList override suspend fun commands(): List = commandsList override suspend fun invalidateCache() { cacheInvalidated = true } override suspend fun invalidateProvidersCache() { providersCacheInvalidated = true } override suspend fun invalidateAgentsCache() { agentsCacheInvalidated = true } override suspend fun invalidateCommandsCache() { commandsCacheInvalidated = true } override suspend fun respondPermission( sessionId: String, permissionId: String, response: PermissionResponse, ): String { respondPermissionCalls = respondPermissionCalls + Triple(sessionId, permissionId, response) respondPermissionThrows?.let { throw it } return "" } override suspend fun findFiles(query: String): List = fileSearchResults override suspend fun listDirectory(path: String): List = directoryListing override suspend fun readFile(path: String): FileContent = fileContent override suspend fun fileStatus(): List { fileStatusThrows?.let { throw it } return fileStatusEntries } } // --------------------------------------------------------------------------- // FakeEventStreamClient // --------------------------------------------------------------------------- class FakeEventStreamClient : EventStreamClient() { val fakeState = MutableStateFlow(EventStreamClient.ConnectionState.Connected) override val state: StateFlow = fakeState.asStateFlow() val fakeEvents = MutableSharedFlow(extraBufferCapacity = NetworkConfig.sseEventBufferCapacity) override val events: kotlinx.coroutines.flow.SharedFlow = fakeEvents.asSharedFlow() override fun triggerReconnect() { /* no-op */ } } // --------------------------------------------------------------------------- // FakeSessionRepository // --------------------------------------------------------------------------- class FakeSessionRepository( private val api: FakeOpencodeApiClient, private val eventStream: FakeEventStreamClient, ) : SessionRepository(api, eventStream) { var sessions: List = emptyList() var createdSession: Session = Session(id = "new-session", title = "New") var messages: List = emptyList() var sendPromptThrows: Throwable? = null var listSessionsThrows: Throwable? = null /** Optional gate that [listSessions] awaits before returning, so tests can hold a refresh in flight. */ var listSessionsGate: kotlinx.coroutines.CompletableDeferred? = null var abortCalls: List = emptyList() private set var deleteCalls: List = emptyList() private set var lastCreatedDirectory: String? = null private set /** When non-null, [observeMessages] returns this flow instead of [messages]. */ var observeMessagesOverride: kotlinx.coroutines.flow.Flow>? = null override suspend fun listSessions(): List { listSessionsGate?.await() listSessionsThrows?.let { throw it } return sessions } override suspend fun createSession(title: String?, directory: String?): Session { lastCreatedDirectory = directory return createdSession } override suspend fun deleteSession(id: String) { deleteCalls = deleteCalls + id } override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId } override suspend fun sendPrompt( sessionId: String, text: String, attachments: List, model: ModelRef?, agent: String?, idempotencyKey: String?, ): MessageWithParts { sendPromptThrows?.let { throw it } return MessageWithParts( info = soy.iko.opencode.data.model.UserMessage(id = "msg-1", sessionID = sessionId), parts = emptyList(), ) } override suspend fun runCommand( sessionId: String, command: String, arguments: String, agent: String?, ): MessageWithParts = MessageWithParts( info = soy.iko.opencode.data.model.UserMessage(id = "cmd-1", sessionID = sessionId), parts = emptyList(), ) override fun observeMessages(sessionId: String): Flow> = observeMessagesOverride ?: flowOf(messages) } // --------------------------------------------------------------------------- // FakeOpencodeConnection // --------------------------------------------------------------------------- class FakeOpencodeConnection( override val api: FakeOpencodeApiClient, override val events: FakeEventStreamClient, override val repository: FakeSessionRepository, profile: ServerProfile, ) : OpencodeConnection(profile) { override suspend fun close() { /* no-op */ } } // --------------------------------------------------------------------------- // FakeSessionPrefsStore // --------------------------------------------------------------------------- class FakeSessionPrefsStore : SessionPrefsStore() { private val _pinned = MutableStateFlow>(emptySet()) private val _archived = MutableStateFlow>(emptySet()) override val pinned: Flow> = _pinned.asStateFlow() override val archived: Flow> = _archived.asStateFlow() override suspend fun setPinned(sessionId: String, pinned: Boolean) { _pinned.value = if (pinned) _pinned.value + sessionId else _pinned.value - sessionId } override suspend fun setArchived(sessionId: String, archived: Boolean) { _archived.value = if (archived) _archived.value + sessionId else _archived.value - sessionId } override suspend fun forget(sessionId: String) { _pinned.value = _pinned.value - sessionId _archived.value = _archived.value - sessionId } } // --------------------------------------------------------------------------- // FakeAttachmentDraftStore / FakeMessageCacheStore // --------------------------------------------------------------------------- class FakeAttachmentDraftStore : AttachmentDraftStore() { private val store = mutableMapOf>() override suspend fun load(sessionId: String): List = store[sessionId].orEmpty() override suspend fun save(sessionId: String, attachments: List) { if (attachments.isEmpty()) store.remove(sessionId) else store[sessionId] = attachments } override suspend fun remove(sessionId: String) { store.remove(sessionId) } } class FakeMessageCacheStore : MessageCacheStore() { private val store = mutableMapOf>() override suspend fun load(profileId: String, sessionId: String): List = store[sessionId].orEmpty() override suspend fun save(profileId: String, sessionId: String, messages: List) { if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages } override suspend fun remove(profileId: String, sessionId: String) { store.remove(sessionId) } } // --------------------------------------------------------------------------- // FakeAppContainer // --------------------------------------------------------------------------- class FakeAppContainer : AppContainer() { val fakeDraftStore = FakeDraftStore() val fakeProfileStore = FakeProfileStore() val fakeSessionPrefsStore = FakeSessionPrefsStore() val fakeAttachmentDraftStore = FakeAttachmentDraftStore() val fakeMessageCacheStore = FakeMessageCacheStore() // In-memory search history (no Context / disk). val fakeSearchHistoryStore = object : soy.iko.opencode.data.repo.SearchHistoryStore() {} val fakeRecentModelsStore = object : soy.iko.opencode.data.repo.RecentModelsStore() {} // In-memory outbox (no Context / disk); exercises the same reduce logic the app uses. val fakeOutboxStore = object : OutboxStore() {} val fakeUnread = MutableStateFlow>(emptyMap()) private val fakeActiveConnection = MutableStateFlow(null) override val draftStore: DraftStore = fakeDraftStore override val profileStore: ProfileStore = fakeProfileStore override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore override val searchHistoryStore: soy.iko.opencode.data.repo.SearchHistoryStore = fakeSearchHistoryStore override val recentModelsStore: soy.iko.opencode.data.repo.RecentModelsStore = fakeRecentModelsStore override val outboxStore: OutboxStore = fakeOutboxStore override val activeConnection: StateFlow = fakeActiveConnection.asStateFlow() override val unread: StateFlow> = fakeUnread.asStateFlow() var connectResult: OpencodeConnection? = null /** When non-null, [connect] throws this instead of returning a connection. */ var connectException: Throwable? = null var connectCalls: List = emptyList() private set var disconnectCalls = 0 private set /** When non-null, [probeServer] returns this instead of building a real client. */ var probeResult: ProbeResult? = null var probeCalls: List = emptyList() private set /** When non-null, [probeWithCredentials] returns this directly. */ var probeWithCredentialsResult: Boolean? = null var probeWithCredentialsCalls: List> = emptyList() private set override fun clearUnread(id: String) { fakeUnread.update { it - id } } override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id" override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}" override fun friendlyErrorFor(t: Throwable, baseUrl: String): String = "test-error: ${t.message ?: t::class.simpleName}" override suspend fun connect(profile: ServerProfile): OpencodeConnection { connectCalls = connectCalls + profile connectException?.let { throw it } val conn = connectResult ?: run { val api = FakeOpencodeApiClient() val events = FakeEventStreamClient() val repo = FakeSessionRepository(api, events) FakeOpencodeConnection(api, events, repo, profile) } fakeActiveConnection.value = conn return conn } override suspend fun disconnect() { disconnectCalls++ fakeActiveConnection.value = null } override suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult { probeCalls = probeCalls + baseUrl return probeResult ?: ProbeResult.Reachable } override suspend fun probeWithCredentials( baseUrl: String, username: String, password: String, requireHttps: Boolean, certPin: String?, ): Boolean { probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password) return probeWithCredentialsResult ?: true } fun setActiveConnection(conn: OpencodeConnection?) { fakeActiveConnection.value = conn } }