Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.opendash.app.e2e

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import com.opendash.app.assistant.model.AssistantMessage
import com.opendash.app.assistant.router.ConversationRouter
import com.opendash.app.assistant.router.RoutingPolicy
import com.opendash.app.e2e.fakes.FakeAssistantProvider
import com.opendash.app.e2e.fakes.FakeTextToSpeech
import com.opendash.app.voice.pipeline.VoicePipeline
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import javax.inject.Inject

/**
* Full LLM round-trip E2E.
*
* Drives the **real** [VoicePipeline] through a non-fast-path utterance
* so it falls into the LLM agent loop:
*
* processUserInput → ConversationRouter.resolveProvider →
* FakeAssistantProvider.send → tts.speak(response.content)
*
* The fake assistant is registered via the production
* `ConversationRouter.registerProvider` API — no `@TestInstallIn`
* needed because the router accepts dynamically-added providers and
* we make the fake the only available one for this test by setting
* Manual policy.
*
* TTS is the same `FakeTextToSpeech` from #460 (already on main),
* resolved by Hilt because `FakeTtsTestModule` is an
* `@TestInstallIn` that runs in the test APK.
*/
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class AssistantProviderE2ETest {

@get:Rule val hiltRule = HiltAndroidRule(this)

@Inject lateinit var voicePipeline: VoicePipeline
@Inject lateinit var fakeTts: FakeTextToSpeech
@Inject lateinit var conversationRouter: ConversationRouter

private lateinit var fakeAssistant: FakeAssistantProvider

@Before
fun setUp() = runTest {
hiltRule.inject()
fakeTts.reset()

// Register a fresh fake provider per test and force the router
// to use it via Manual policy. This deliberately bypasses any
// production providers that the model-download flow may have
// registered — we want hermetic LLM-call assertions.
fakeAssistant = FakeAssistantProvider()
conversationRouter.registerProvider(fakeAssistant)
conversationRouter.selectProvider(fakeAssistant.id)
}

@Test
fun llm_response_is_spoken_via_tts() = runTest {
val canned = "Quantum computing uses qubits."
fakeAssistant.queueResponse(AssistantMessage.Assistant(content = canned))

// Ambiguous information query — AgentIntentHint forces a fall-
// through to the LLM path, so this guarantees we hit
// FakeAssistantProvider.send and not a fast-path matcher.
val utterance = "explain quantum computing"

withTimeout(PIPELINE_TIMEOUT_MS) {
voicePipeline.processUserInput(utterance)
}

// The provider was actually consulted with the user message.
assertThat(fakeAssistant.sentMessages).isNotEmpty()
val lastBatch = fakeAssistant.sentMessages.last()
val userInBatch = lastBatch.filterIsInstance<AssistantMessage.User>()
assertThat(userInBatch.any { it.content == utterance }).isTrue()

// The canned response was spoken.
assertThat(fakeTts.spokenTexts).contains(canned)
}

@Test
fun fake_provider_is_resolved_under_manual_policy() = runTest {
// Independent guard: setUp already selected fakeAssistant. Make
// sure resolveProvider returns it so future tests don't get
// silently routed to a real provider if model download finishes
// mid-suite.
assertThat(conversationRouter.policy.value)
.isInstanceOf(RoutingPolicy.Manual::class.java)
val resolved = conversationRouter.resolveProvider("hello")
assertThat(resolved.id).isEqualTo(fakeAssistant.id)
}

private companion object {
// LLM round-trip is two pipeline phases (Thinking + Speaking)
// plus filler-phrase debounce. Generous to ride out emulator
// variance.
const val PIPELINE_TIMEOUT_MS = 10_000L
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.opendash.app.e2e.fakes

import com.opendash.app.assistant.model.AssistantMessage
import com.opendash.app.assistant.model.AssistantSession
import com.opendash.app.assistant.provider.AssistantProvider
import com.opendash.app.assistant.provider.ProviderCapabilities
import com.opendash.app.tool.ToolSchema
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import java.util.concurrent.CopyOnWriteArrayList

/**
* Test double for [AssistantProvider] that returns scripted responses
* instead of running an LLM.
*
* The production [com.opendash.app.assistant.router.ConversationRouter]
* accepts dynamically registered providers via [register], so tests can
* inject this fake at runtime — no `@TestInstallIn` needed.
*
* Usage:
* ```
* fakeAssistant.queueResponse(AssistantMessage.Assistant(content = "hi"))
* conversationRouter.registerProvider(fakeAssistant)
* conversationRouter.selectProvider(fakeAssistant.id) // make it active
* ```
*
* Records every `send()` call into [sentMessages] for assertions.
*/
class FakeAssistantProvider(
override val id: String = "fake-assistant",
override val displayName: String = "Fake Assistant",
override val capabilities: ProviderCapabilities = ProviderCapabilities(
supportsStreaming = false,
supportsTools = true,
maxContextTokens = 8192,
modelName = "fake-1",
// Mark as local so the Auto policy won't gate this provider on
// network state — tests run on emulators that may not have
// internet, and we never want to hit the real network anyway.
isLocal = true
)
) : AssistantProvider {

private val responses = ArrayDeque<AssistantMessage>()
private val _sentMessages = CopyOnWriteArrayList<List<AssistantMessage>>()
val sentMessages: List<List<AssistantMessage>> get() = _sentMessages.toList()

fun queueResponse(message: AssistantMessage) {
responses.addLast(message)
}

fun reset() {
responses.clear()
_sentMessages.clear()
}

override suspend fun startSession(config: Map<String, String>): AssistantSession =
AssistantSession(providerId = id)

override suspend fun endSession(session: AssistantSession) = Unit

override suspend fun send(
session: AssistantSession,
messages: List<AssistantMessage>,
tools: List<ToolSchema>
): AssistantMessage {
_sentMessages.add(messages.toList())
return responses.removeFirstOrNull()
// Fall back to a benign assistant message so the pipeline
// doesn't NPE when a test forgets to queue. The assertion
// failure surfaces in the test, not as a crash.
?: AssistantMessage.Assistant(content = "(no fake response queued)")
}

override fun sendStreaming(
session: AssistantSession,
messages: List<AssistantMessage>,
tools: List<ToolSchema>
): Flow<AssistantMessage.Delta> = flowOf()

override suspend fun isAvailable(): Boolean = true
override suspend fun latencyMs(): Long = 1L
}
Loading