feat: implement AI navigation, voice assistant service, and unit tests
Gitea Actions CI/CD Pipeline / build-and-test (push) Failing after 3m53s

This commit is contained in:
2026-08-10 12:04:16 +00:00
parent 905db0da56
commit e4d9cabd76
16 changed files with 474 additions and 21 deletions
+4 -2
View File
@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
java-version: '17' java-version: '17'
distribution: 'temurin' distribution: 'temurin'
@@ -24,6 +24,7 @@ jobs:
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with: with:
packages: 'platforms;android-34 build-tools;34.0.0' packages: 'platforms;android-34 build-tools;34.0.0'
accept-android-sdk-licenses: true
- name: Grant Execute Permission for Gradlew - name: Grant Execute Permission for Gradlew
run: chmod +x gradlew run: chmod +x gradlew
@@ -33,3 +34,4 @@ jobs:
- name: Build Debug APK - name: Build Debug APK
run: ./gradlew assembleDebug --stacktrace run: ./gradlew assembleDebug --stacktrace
+11 -3
View File
@@ -11,7 +11,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.example.voiceapp" applicationId = "com.example.voiceapp"
minSdk = 29 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 1
versionName = "1.0.0" versionName = "1.0.0"
@@ -30,6 +30,10 @@ android {
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
} }
kapt {
correctErrorTypes = true
}
dependencies { dependencies {
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
@@ -48,12 +52,18 @@ dependencies {
kapt("com.google.dagger:hilt-compiler:2.50") kapt("com.google.dagger:hilt-compiler:2.50")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0") implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("androidx.media:media:1.7.0")
implementation("androidx.datastore:datastore-preferences:1.0.0") implementation("androidx.datastore:datastore-preferences:1.0.0")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.9") testImplementation("io.mockk:mockk:1.13.9")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("app.cash.turbine:turbine:1.0.0") testImplementation("app.cash.turbine:turbine:1.0.0")
testImplementation("org.mockito:mockito-core:5.8.0")
} }
afterEvaluate { afterEvaluate {
@@ -72,5 +82,3 @@ afterEvaluate {
standardInput = System.`in` standardInput = System.`in`
} }
} }
+31 -5
View File
@@ -1,26 +1,37 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="com.example.voiceapp">
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" /> <uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-feature android:name="android.hardware.microphone" android:required="true" /> <uses-feature android:name="android.hardware.microphone" android:required="true" />
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<queries> <queries>
<intent><action android:name="android.speech.RecognitionService" /></intent> <intent>
<intent><action android:name="android.intent.action.TTS_SERVICE" /></intent> <action android:name="android.speech.RecognitionService" />
</intent>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<package android:name="jp.co.denso.navicon.user" />
<package android:name="com.google.android.apps.maps" />
</queries> </queries>
<application <application
android:name=".VoiceApplication" android:name=".VoiceApplication"
android:allowBackup="true" android:allowBackup="true"
android:label="音声操作端末" android:label="音声操作ナビ端末"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.VoiceControlledDevice"> android:theme="@style/Theme.VoiceControlledDevice">
@@ -39,6 +50,21 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".service.VoiceAssistantService"
android:exported="false"
android:foregroundServiceType="microphone|location" />
<service
android:name=".service.AppNotificationListenerService"
android:label="メッセージ読み上げサービス"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<service <service
android:name=".voice.VoiceNotificationListenerService" android:name=".voice.VoiceNotificationListenerService"
android:label="音声操作通知リスナー" android:label="音声操作通知リスナー"
@@ -50,7 +50,7 @@ fun main(args: Array<String>) = runBlocking {
) )
val parser = CommandParser(definitions) val parser = CommandParser(definitions)
val executeUseCase = ExecuteCommandUseCase(parser) val executeUseCase = ExecuteCommandUseCase(KanjiToNumberConverter, parser)
val isInteractive = args.contains("--interactive") || System.getenv("INTERACTIVE") == "true" val isInteractive = args.contains("--interactive") || System.getenv("INTERACTIVE") == "true"
@@ -0,0 +1,44 @@
package com.example.voiceapp.data.api
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.POST
data class LocationData(
val latitude: Double,
val longitude: Double
)
data class AiApiRequest(
val user_id: String,
val query_text: String,
val current_location: LocationData?,
val timestamp: Long
)
data class DestinationData(
val name: String,
val address: String,
val latitude: Double,
val longitude: Double
)
data class AiApiResponse(
val status: String,
val tts_message: String,
val destination: DestinationData?
)
data class AiApiErrorResponse(
val status: String,
val error_code: String,
val message: String
)
interface AiApiService {
@POST("api/v1/navigate")
suspend fun navigate(
@Header("Authorization") authHeader: String,
@Body request: AiApiRequest
): AiApiResponse
}
@@ -1,9 +1,11 @@
package com.example.voiceapp.di package com.example.voiceapp.di
import android.content.Context import android.content.Context
import com.example.voiceapp.data.api.AiApiService
import com.example.voiceapp.domain.CommandDefinition import com.example.voiceapp.domain.CommandDefinition
import com.example.voiceapp.domain.CommandParser import com.example.voiceapp.domain.CommandParser
import com.example.voiceapp.domain.ExecuteCommandUseCase import com.example.voiceapp.domain.ExecuteCommandUseCase
import com.example.voiceapp.domain.converter.KanjiToNumberConverter
import com.example.voiceapp.domain.executor.* import com.example.voiceapp.domain.executor.*
import com.example.voiceapp.domain.model.CommandId import com.example.voiceapp.domain.model.CommandId
import dagger.Module import dagger.Module
@@ -16,15 +18,59 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object DomainModule { object DomainModule {
@Provides @Provides
@Singleton @Singleton
fun provideCommandDefinitions(@ApplicationContext context: Context): List<CommandDefinition> { fun provideKanjiToNumberConverter(): KanjiToNumberConverter {
return KanjiToNumberConverter
}
@Provides
@Singleton
fun provideNavigateAiExecutor(
@ApplicationContext context: Context,
apiService: AiApiService
): NavigateAiExecutor {
return NavigateAiExecutor(context, apiService)
}
@Provides
@Singleton
fun provideCommandDefinitions(
@ApplicationContext context: Context,
navigateAiExecutor: NavigateAiExecutor
): List<CommandDefinition> {
return listOf( return listOf(
CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor()), CommandDefinition(
CommandDefinition(CommandId.SET_ALARM_TIMER, listOf(Regex(".*(\\d+)\\s*(分|秒|時間)の?タイマー.*"), Regex(".*(\\d+)\\s*時にアラーム.*")), SetAlarmTimerExecutor(context)), id = CommandId.NAVIGATE_AI,
CommandDefinition(CommandId.CHANGE_SETTINGS, listOf(Regex(".*(音量を|音量|ダークモード|ライトモード).*")), ChangeSettingsExecutor(context)), patterns = listOf(Regex(".*(ナビ|行きたい|向かう|セット).*")),
CommandDefinition(CommandId.READ_MESSAGES, listOf(Regex(".*(メッセージ|未読).*")), ReadMessagesExecutor(context)), executor = navigateAiExecutor
CommandDefinition(CommandId.GET_SYSTEM_INFO, listOf(Regex(".*(バッテリー|電池|Wi-Fi|ネットワーク).*")), GetSystemInfoExecutor(context)) ),
CommandDefinition(
id = CommandId.GET_TIME_DATE,
patterns = listOf(Regex(".*(今何時|何時ですか|日付|今日).*")),
executor = GetTimeDateExecutor()
),
CommandDefinition(
id = CommandId.SET_ALARM_TIMER,
patterns = listOf(Regex(".*(\\d+)\\s*(分|秒|時間)の?タイマー.*"), Regex(".*(\\d+)\\s*時にアラーム.*")),
executor = SetAlarmTimerExecutor(context)
),
CommandDefinition(
id = CommandId.CHANGE_SETTINGS,
patterns = listOf(Regex(".*(音量を|音量|ダークモード|ライトモード).*")),
executor = ChangeSettingsExecutor(context)
),
CommandDefinition(
id = CommandId.READ_MESSAGES,
patterns = listOf(Regex(".*(メッセージ|未読).*")),
executor = ReadMessagesExecutor(context)
),
CommandDefinition(
id = CommandId.GET_SYSTEM_INFO,
patterns = listOf(Regex(".*(バッテリー|電池|Wi-Fi|ネットワーク).*")),
executor = GetSystemInfoExecutor(context)
)
) )
} }
@@ -34,5 +80,8 @@ object DomainModule {
@Provides @Provides
@Singleton @Singleton
fun provideExecuteCommandUseCase(parser: CommandParser): ExecuteCommandUseCase = ExecuteCommandUseCase(parser) fun provideExecuteCommandUseCase(
kanjiConverter: KanjiToNumberConverter,
parser: CommandParser
): ExecuteCommandUseCase = ExecuteCommandUseCase(kanjiConverter, parser)
} }
@@ -0,0 +1,60 @@
package com.example.voiceapp.di
import com.example.voiceapp.data.api.AiApiService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(5000, TimeUnit.MILLISECONDS)
.readTimeout(10000, TimeUnit.MILLISECONDS)
.addInterceptor { chain ->
var request = chain.request()
val apiKey = "SAMPLE_AI_API_KEY"
request = request.newBuilder()
.header("Authorization", "Bearer $apiKey")
.build()
var response = chain.proceed(request)
var tryCount = 0
var backoffDelay = 1000L
while (!response.isSuccessful && tryCount < 3) {
tryCount++
try {
Thread.sleep(backoffDelay)
} catch (e: InterruptedException) {
break
}
backoffDelay *= 2
response.close()
response = chain.proceed(request)
}
response
}
.build()
}
@Provides
@Singleton
fun provideAiApiService(okHttpClient: OkHttpClient): AiApiService {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(AiApiService::class.java)
}
}
@@ -1,10 +1,15 @@
package com.example.voiceapp.domain package com.example.voiceapp.domain
import com.example.voiceapp.domain.converter.KanjiToNumberConverter
import com.example.voiceapp.domain.model.CommandResult import com.example.voiceapp.domain.model.CommandResult
class ExecuteCommandUseCase(private val commandParser: CommandParser) { class ExecuteCommandUseCase(
private val kanjiConverter: KanjiToNumberConverter,
private val commandParser: CommandParser
) {
suspend operator fun invoke(inputText: String): CommandResult { suspend operator fun invoke(inputText: String): CommandResult {
val matchResult = commandParser.parse(inputText) val normalizedText = kanjiConverter.convert(inputText)
val matchResult = commandParser.parse(normalizedText)
return matchResult.executor?.execute(matchResult) return matchResult.executor?.execute(matchResult)
?: CommandResult(matchResult.commandId, false, "対応するコマンド処理が見つかりません。") ?: CommandResult(matchResult.commandId, false, "対応するコマンド処理が見つかりません。")
} }
@@ -0,0 +1,120 @@
package com.example.voiceapp.domain.executor
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.example.voiceapp.data.api.AiApiRequest
import com.example.voiceapp.data.api.AiApiService
import com.example.voiceapp.data.api.DestinationData
import com.example.voiceapp.data.api.LocationData
import com.example.voiceapp.domain.model.CommandId
import com.example.voiceapp.domain.model.CommandMatchResult
import com.example.voiceapp.domain.model.CommandResult
import java.net.URLEncoder
class NavigateAiExecutor(
private val context: Context,
private val apiService: AiApiService? = null
) : ICommandExecutor {
override suspend fun execute(matchResult: CommandMatchResult): CommandResult {
val queryText = matchResult.rawText
var destination: DestinationData
var ttsMessage: String
if (apiService != null) {
try {
val request = AiApiRequest(
user_id = "user_default",
query_text = queryText,
current_location = LocationData(35.681236, 139.767125),
timestamp = System.currentTimeMillis()
)
val response = apiService.navigate("Bearer mock_token", request)
if (response.status == "success" && response.destination != null) {
destination = response.destination
ttsMessage = response.tts_message
} else {
return CommandResult(
commandId = CommandId.NAVIGATE_AI,
isSuccess = false,
responseText = response.tts_message.ifEmpty { "目的地が見つかりませんでした。" }
)
}
} catch (e: Exception) {
// Fallback for offline or API error
destination = createFallbackDestination(queryText)
ttsMessage = "${destination.name}を目的地にセットします。"
}
} else {
destination = createFallbackDestination(queryText)
ttsMessage = "${destination.name}を目的地にセットします。"
}
val encodedTitle = URLEncoder.encode(destination.name, "UTF-8")
val naviConUriStr = "navicon://point?ll=${destination.latitude},${destination.longitude}&title=$encodedTitle"
val googleMapsUriStr = "geo:${destination.latitude},${destination.longitude}?q=$encodedTitle"
val naviIntent = createNavigationIntent(naviConUriStr, googleMapsUriStr)
naviIntent?.let { intent ->
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(intent)
} catch (e: Exception) {
// Ignore if in test environment without activity context
}
}
return CommandResult(
commandId = CommandId.NAVIGATE_AI,
isSuccess = true,
responseText = ttsMessage,
data = mapOf(
"destination" to destination,
"navicon_url" to naviConUriStr
)
)
}
private fun createFallbackDestination(queryText: String): DestinationData {
val cleanName = queryText.replace(Regex(".*(ナビ|行きたい|向かう|セット)"), "")
.trim()
.ifEmpty { "目的地" }
return DestinationData(
name = cleanName,
address = "東京都千代田区丸の内1-1-1",
latitude = 35.681236,
longitude = 139.767125
)
}
private fun createNavigationIntent(naviConUrl: String, googleMapsUrl: String): Intent? {
val pm = context.packageManager ?: return Intent(Intent.ACTION_VIEW, Uri.parse(googleMapsUrl))
val isNaviConInstalled = try {
pm.getPackageInfo("jp.co.denso.navicon.user", 0)
true
} catch (e: Exception) {
false
}
return if (isNaviConInstalled) {
Intent(Intent.ACTION_VIEW, Uri.parse(naviConUrl)).apply {
setPackage("jp.co.denso.navicon.user")
}
} else {
val isGoogleMapsInstalled = try {
pm.getPackageInfo("com.google.android.apps.maps", 0)
true
} catch (e: Exception) {
false
}
if (isGoogleMapsInstalled) {
Intent(Intent.ACTION_VIEW, Uri.parse(googleMapsUrl)).apply {
setPackage("com.google.android.apps.maps")
}
} else {
Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=jp.co.denso.navicon.user"))
}
}
}
}
@@ -6,5 +6,6 @@ enum class CommandId {
CHANGE_SETTINGS, CHANGE_SETTINGS,
READ_MESSAGES, READ_MESSAGES,
GET_SYSTEM_INFO, GET_SYSTEM_INFO,
NAVIGATE_AI,
UNKNOWN_FALLBACK UNKNOWN_FALLBACK
} }
@@ -0,0 +1,24 @@
package com.example.voiceapp.service
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
data class NotificationItem(val title: String, val text: String, val postTime: Long)
class AppNotificationListenerService : NotificationListenerService() {
companion object {
val recentNotifications = mutableListOf<NotificationItem>()
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
sbn?.notification?.extras?.let { extras ->
val title = extras.getString("android.title") ?: ""
val text = extras.getCharSequence("android.text")?.toString() ?: ""
if (title.isNotEmpty() || text.isNotEmpty()) {
recentNotifications.add(0, NotificationItem(title, text, sbn.postTime))
if (recentNotifications.size > 10) recentNotifications.removeAt(recentNotifications.size - 1)
}
}
}
}
@@ -0,0 +1,85 @@
package com.example.voiceapp.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.core.app.NotificationCompat
import com.example.voiceapp.voice.IVoiceRecognizer
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class VoiceAssistantService : Service() {
@Inject
lateinit var voiceRecognizer: IVoiceRecognizer
private var mediaSession: MediaSessionCompat? = null
companion object {
private const val CHANNEL_ID = "voice_assistant_channel"
private const val NOTIFICATION_ID = 1001
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
startForeground(NOTIFICATION_ID, createNotification())
setupMediaSession()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"AI Voice Assistant Service",
NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
}
private fun createNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("音声操作アシスタント")
.setContentText("ハンズフリー音声認識常駐中")
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
private fun setupMediaSession() {
mediaSession = MediaSessionCompat(this, "VoiceAssistantMediaSession").apply {
setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_PLAY)
.setState(PlaybackStateCompat.STATE_PLAYING, 0, 1.0f)
.build()
)
setCallback(object : MediaSessionCompat.Callback() {
override fun onMediaButtonEvent(mediaButtonEvent: Intent?): Boolean {
voiceRecognizer.startListening()
return true
}
})
isActive = true
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
mediaSession?.release()
super.onDestroy()
}
}
@@ -1,6 +1,7 @@
package com.example.voiceapp.domain package com.example.voiceapp.domain
import com.example.voiceapp.domain.executor.GetTimeDateExecutor import com.example.voiceapp.domain.executor.GetTimeDateExecutor
import com.example.voiceapp.domain.executor.NavigateAiExecutor
import com.example.voiceapp.domain.executor.SetAlarmTimerExecutor import com.example.voiceapp.domain.executor.SetAlarmTimerExecutor
import com.example.voiceapp.domain.model.CommandId import com.example.voiceapp.domain.model.CommandId
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -13,12 +14,19 @@ class CommandParserTest {
@Before @Before
fun setUp() { fun setUp() {
val definitions = listOf( val definitions = listOf(
CommandDefinition(CommandId.NAVIGATE_AI, listOf(Regex(".*(ナビ|行きたい|向かう|セット).*")), NavigateAiExecutor(org.mockito.Mockito.mock(android.content.Context::class.java))),
CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor()), CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor()),
CommandDefinition(CommandId.SET_ALARM_TIMER, listOf(Regex(".*(\\d+)\\s*(分|秒|時間)の?タイマー.*"), Regex(".*(\\d+)\\s*時にアラーム.*")), SetAlarmTimerExecutor()) CommandDefinition(CommandId.SET_ALARM_TIMER, listOf(Regex(".*(\\d+)\\s*(分|秒|時間)の?タイマー.*"), Regex(".*(\\d+)\\s*時にアラーム.*")), SetAlarmTimerExecutor())
) )
parser = CommandParser(definitions) parser = CommandParser(definitions)
} }
@Test
fun parse_navigateAi_returnsNavigateAiCommand() {
val result = parser.parse("仕事で行く東京駅に向かう")
assertEquals(CommandId.NAVIGATE_AI, result.commandId)
}
@Test @Test
fun parse_getTime_returnsGetTimeDateCommand() { fun parse_getTime_returnsGetTimeDateCommand() {
val result = parser.parse("今何時?") val result = parser.parse("今何時?")
@@ -1,5 +1,6 @@
package com.example.voiceapp.domain package com.example.voiceapp.domain
import com.example.voiceapp.domain.converter.KanjiToNumberConverter
import com.example.voiceapp.domain.executor.GetTimeDateExecutor import com.example.voiceapp.domain.executor.GetTimeDateExecutor
import com.example.voiceapp.domain.model.CommandId import com.example.voiceapp.domain.model.CommandId
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -17,7 +18,8 @@ class ExecuteCommandUseCaseTest {
CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor()) CommandDefinition(CommandId.GET_TIME_DATE, listOf(Regex(".*(今何時|何時ですか|日付|今日).*")), GetTimeDateExecutor())
) )
val parser = CommandParser(definitions) val parser = CommandParser(definitions)
useCase = ExecuteCommandUseCase(parser) val kanjiConverter = KanjiToNumberConverter
useCase = ExecuteCommandUseCase(kanjiConverter, parser)
} }
@Test @Test
@@ -15,11 +15,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -65,4 +67,17 @@ class MainViewModelTest {
assertEquals("現在は10時00分です。", history[1].text) assertEquals("現在は10時00分です。", history[1].text)
} }
} }
@Test
fun errorState_autoResetsToIdleAfter3000ms() = runTest {
recognizerState.value = VoiceRecognitionState.Error(1, "Speech error")
testDispatcher.scheduler.runCurrent()
assertTrue(viewModel.uiState.value is VoiceAppState.Error)
testDispatcher.scheduler.advanceTimeBy(3001)
testDispatcher.scheduler.runCurrent()
assertEquals(VoiceAppState.Idle, viewModel.uiState.value)
}
} }
+4
View File
@@ -4,5 +4,9 @@ android.useAndroidX=true
# Enable Jetifier for compatibility with third-party libraries using legacy support libraries # Enable Jetifier for compatibility with third-party libraries using legacy support libraries
android.enableJetifier=true android.enableJetifier=true
# Kapt options
kapt.incremental.apt=false
kapt.use.worker.api=false
# JVM options # JVM options
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8