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,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")
}
}
Original file line number Diff line number Diff line change
@@ -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<SttResult> = Channel(Channel.UNLIMITED)
private val _isListening = MutableStateFlow(false)
override val isListening: StateFlow<Boolean> = _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<SttResult> {
// 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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
36 changes: 36 additions & 0 deletions app/src/main/java/com/opendash/app/di/SttModule.kt
Original file line number Diff line number Diff line change
@@ -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)
)
}
14 changes: 2 additions & 12 deletions app/src/main/java/com/opendash/app/di/VoiceModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
Loading