diff --git a/app/src/androidTest/java/com/opendash/app/e2e/FakeSttPipelineE2ETest.kt b/app/src/androidTest/java/com/opendash/app/e2e/FakeSttPipelineE2ETest.kt new file mode 100644 index 00000000..61971d66 --- /dev/null +++ b/app/src/androidTest/java/com/opendash/app/e2e/FakeSttPipelineE2ETest.kt @@ -0,0 +1,92 @@ +package com.opendash.app.e2e + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.opendash.app.e2e.fakes.FakeSpeechToText +import com.opendash.app.voice.stt.SpeechToText +import com.opendash.app.voice.stt.SttResult +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import javax.inject.Inject + +/** + * Verifies the [SpeechToText] @TestInstallIn swap works end-to-end: + * the Hilt graph hands every consumer the [FakeSpeechToText] singleton, + * and queued results flow through to a collector. + * + * `VoicePipeline.startListening()` itself is service-aware (audio focus, + * VoiceService wake-word pause, beep playback) so wiring the **full** + * wake→STT→tool→TTS chain into an emulator @Test is left to a follow-up + * PR that uses `androidx.test.rule.ServiceTestRule` (or an extracted + * non-service `AudioFocusController` shim). Until then, this guard at + * least catches DI regressions on the STT swap pattern. + */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class FakeSttPipelineE2ETest { + + @get:Rule val hiltRule = HiltAndroidRule(this) + + @Inject lateinit var fakeStt: FakeSpeechToText + @Inject lateinit var realInterface: SpeechToText + + @Before + fun setUp() { + hiltRule.inject() + fakeStt.reset() + } + + @Test + fun hilt_resolves_fake_for_speech_to_text_interface() { + // Both injection points must resolve to the same singleton — + // otherwise tests that inject the interface (e.g. via a real + // production component) would not see queued results. + assertThat(realInterface).isSameInstanceAs(fakeStt) + } + + @Test + fun queued_results_emit_in_order_and_terminate_on_final() = runTest { + fakeStt.queue(SttResult.Partial("set timer")) + fakeStt.queue(SttResult.Partial("set timer for 5")) + fakeStt.queue(SttResult.Final("set timer for 5 minutes")) + + val collected = realInterface.startListening().toList() + + assertThat(collected).hasSize(3) + assertThat(collected[0]).isEqualTo(SttResult.Partial("set timer")) + assertThat(collected[2]).isInstanceOf(SttResult.Final::class.java) + assertThat((collected[2] as SttResult.Final).text) + .isEqualTo("set timer for 5 minutes") + } + + @Test + fun error_result_terminates_listening() = runTest { + fakeStt.queue(SttResult.Partial("hello")) + fakeStt.queue(SttResult.Error("NETWORK")) + + val collected = realInterface.startListening().toList() + + assertThat(collected).hasSize(2) + assertThat(collected[1]).isInstanceOf(SttResult.Error::class.java) + } + + @Test + fun reset_allows_a_second_listening_session() = runTest { + fakeStt.queue(SttResult.Final("first")) + val first = realInterface.startListening().toList() + assertThat(first).hasSize(1) + + // Without reset (or implicit channel renewal in startListening), a + // second call would see a closed channel. + fakeStt.queue(SttResult.Final("second")) + val second = realInterface.startListening().toList() + assertThat(second).hasSize(1) + assertThat((second[0] as SttResult.Final).text).isEqualTo("second") + } +} diff --git a/app/src/androidTest/java/com/opendash/app/e2e/fakes/FakeSpeechToText.kt b/app/src/androidTest/java/com/opendash/app/e2e/fakes/FakeSpeechToText.kt new file mode 100644 index 00000000..454c8275 --- /dev/null +++ b/app/src/androidTest/java/com/opendash/app/e2e/fakes/FakeSpeechToText.kt @@ -0,0 +1,70 @@ +package com.opendash.app.e2e.fakes + +import com.opendash.app.voice.stt.SpeechToText +import com.opendash.app.voice.stt.SttResult +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.consumeAsFlow + +/** + * Test double for [SpeechToText] that lets a test programmatically push + * [SttResult]s into the pipeline without needing a real microphone or + * SpeechRecognizer service. + * + * Usage in a test: + * ``` + * @Inject lateinit var fakeStt: FakeSpeechToText + * fakeStt.queue(SttResult.Final("set timer for 5 minutes")) + * voicePipeline.startListening() // collects from this fake + * ``` + * + * Each call to [startListening] returns a fresh Flow backed by a fresh + * channel so tests can re-enter listening mode multiple times without + * cross-talk between iterations. + */ +class FakeSpeechToText : SpeechToText { + + private var channel: Channel = Channel(Channel.UNLIMITED) + private val _isListening = MutableStateFlow(false) + override val isListening: StateFlow = _isListening.asStateFlow() + + /** + * Push an [SttResult] that the next (or current) collector will receive. + * Calls before [startListening] are buffered until a collector subscribes. + */ + fun queue(result: SttResult) { + channel.trySend(result) + // A `Final` or `Error` result terminates the current listening session + // — close the channel so the collector sees the Flow complete and + // VoicePipeline transitions out of Listening. + if (result is SttResult.Final || result is SttResult.Error) { + channel.close() + } + } + + override fun startListening(): Flow { + // Reset for a new listening session if the previous one already + // completed. A test may call queue → startListening → queue + // multiple times within one @Test method. + if (channel.isClosedForSend) { + channel = Channel(Channel.UNLIMITED) + } + _isListening.value = true + val current = channel + return current.consumeAsFlow() + } + + override fun stopListening() { + _isListening.value = false + channel.close() + } + + fun reset() { + _isListening.value = false + if (!channel.isClosedForSend) channel.close() + channel = Channel(Channel.UNLIMITED) + } +} diff --git a/app/src/androidTest/java/com/opendash/app/e2e/fakes/FakeSttTestModule.kt b/app/src/androidTest/java/com/opendash/app/e2e/fakes/FakeSttTestModule.kt new file mode 100644 index 00000000..cb9affc0 --- /dev/null +++ b/app/src/androidTest/java/com/opendash/app/e2e/fakes/FakeSttTestModule.kt @@ -0,0 +1,36 @@ +package com.opendash.app.e2e.fakes + +import com.opendash.app.di.SttModule +import com.opendash.app.voice.stt.SpeechToText +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +/** + * Replaces [SttModule] in instrumented tests so the production + * [com.opendash.app.voice.stt.DelegatingSttProvider] (which would talk to + * Android's `SpeechRecognizer`) is swapped for a programmatic + * [FakeSpeechToText]. + * + * Tests inject `FakeSpeechToText` directly via + * `@Inject lateinit var fakeStt: FakeSpeechToText` — the binding below + * registers the same singleton against both [SpeechToText] and the + * concrete fake type. + */ +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [SttModule::class] +) +object FakeSttTestModule { + + @Provides + @Singleton + fun provideFakeStt(): FakeSpeechToText = FakeSpeechToText() + + @Provides + @Singleton + fun provideSpeechToText(fake: FakeSpeechToText): SpeechToText = fake +} diff --git a/app/src/main/java/com/opendash/app/di/SttModule.kt b/app/src/main/java/com/opendash/app/di/SttModule.kt new file mode 100644 index 00000000..6a6ba3f0 --- /dev/null +++ b/app/src/main/java/com/opendash/app/di/SttModule.kt @@ -0,0 +1,36 @@ +package com.opendash.app.di + +import android.content.Context +import com.opendash.app.data.preferences.AppPreferences +import com.opendash.app.voice.stt.AndroidSttProvider +import com.opendash.app.voice.stt.DelegatingSttProvider +import com.opendash.app.voice.stt.SpeechToText +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * SpeechToText binding lives in its own module so instrumented tests can + * swap the implementation via `@TestInstallIn(replaces = [SttModule::class])` + * without rebuilding the rest of the voice graph (TTS, VoicePipeline, + * LatencyRecorder). + * + * See `app/src/androidTest/.../FakeSttTestModule.kt`. + */ +@Module +@InstallIn(SingletonComponent::class) +object SttModule { + + @Provides + @Singleton + fun provideSpeechToText( + @ApplicationContext context: Context, + preferences: AppPreferences + ): SpeechToText = DelegatingSttProvider( + preferences = preferences, + android = AndroidSttProvider(context) + ) +} diff --git a/app/src/main/java/com/opendash/app/di/VoiceModule.kt b/app/src/main/java/com/opendash/app/di/VoiceModule.kt index bb8fd5dd..a1bc7b66 100644 --- a/app/src/main/java/com/opendash/app/di/VoiceModule.kt +++ b/app/src/main/java/com/opendash/app/di/VoiceModule.kt @@ -10,8 +10,6 @@ import com.opendash.app.voice.fastpath.FastPathRouter import com.opendash.app.voice.metrics.LatencyRecorder import com.opendash.app.voice.pipeline.FastPathLlmPolisher import com.opendash.app.voice.pipeline.VoicePipeline -import com.opendash.app.voice.stt.AndroidSttProvider -import com.opendash.app.voice.stt.DelegatingSttProvider import com.opendash.app.voice.stt.SpeechToText import com.opendash.app.voice.tts.TextToSpeech import com.squareup.moshi.Moshi @@ -26,16 +24,8 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object VoiceModule { - @Provides - @Singleton - fun provideSpeechToText( - @ApplicationContext context: Context, - preferences: AppPreferences - ): SpeechToText = DelegatingSttProvider( - preferences = preferences, - android = AndroidSttProvider(context) - ) - + // provideSpeechToText moved to SttModule so @TestInstallIn can swap STT + // independently of the rest of the voice graph. // provideTextToSpeech moved to TtsModule so @TestInstallIn can swap the // TTS implementation independently of the rest of the voice graph.