package pw.binom.agentik.desktop.voice import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.fail import kotlin.test.assertTrue /** * Состояния и колбэки [VoiceController] на фейковых источнике аудио и * распознавателе — без микрофона и без нативных библиотек. * * Время настоящее (`runBlocking` + ожидание состояния), а не `runTest` с * виртуальным: контроллер считает на выделенном потоке `Dispatchers.Default`. */ class VoiceControllerTest { private val chunk = ByteArray(4) { it.toByte() } private fun finiteMic(vararg chunks: ByteArray): MicSource = MicSource { _, _ -> flow { chunks.forEach { emit(it) } } } private fun endlessMic(chunk: ByteArray = ByteArray(4) { 1 }): MicSource = MicSource { _, _ -> flow { while (true) { emit(chunk) delay(5) } } } private class FakeRecognizer( private val partials: List = emptyList(), private val finalText: String = "", override val speechDetected: Boolean = true, ) : VoiceRecognizer { val fed = mutableListOf() var finished = false var closed = false private var partialCalls = 0 override fun feed(pcm: ByteArray) { fed += pcm } override fun partial(): String? { if (partials.isEmpty()) return null return partials[minOf(partialCalls++, partials.size - 1)] } override fun finish(): String { finished = true return finalText } override fun close() { closed = true } } private class Recorder { val states = mutableListOf() val partials = mutableListOf() val finals = mutableListOf() val errors = mutableListOf() } private fun controller( scope: CoroutineScope, mic: MicSource, recognizer: VoiceRecognizer, rec: Recorder, ) = VoiceController( scope = scope, recognizers = VoiceRecognizerFactory { recognizer }, mic = mic, onState = { rec.states += it }, onPartial = { rec.partials += it }, onFinal = { rec.finals += it }, onError = { rec.errors += it }, ) @Test fun `stop delivers final text and closes recognizer`() = runBlocking { val rec = Recorder() val recognizer = FakeRecognizer(partials = listOf("при"), finalText = "привет") val voice = controller(this, endlessMic(), recognizer, rec) voice.start() await("микрофон начал писать") { voice.state == VoiceState.RECORDING } await("пришёл промежуточный текст") { rec.partials.isNotEmpty() } assertEquals(listOf("при"), rec.partials) voice.stop() awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() } awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE } assertEquals(listOf("привет"), rec.finals) assertTrue(recognizer.finished, "finish() должен быть вызван") assertTrue(recognizer.closed, "recognizer должен быть закрыт") assertEquals(listOf(VoiceState.PREPARING, VoiceState.RECORDING, VoiceState.RECOGNIZING, VoiceState.IDLE), rec.states) } @Test fun `cancelled recording emits nothing`() = runBlocking { val rec = Recorder() val recognizer = FakeRecognizer(partials = listOf("при"), finalText = "привет") val voice = controller(this, endlessMic(), recognizer, rec) voice.start() await("микрофон начал писать") { voice.state == VoiceState.RECORDING } await("пришёл промежуточный текст") { rec.partials.isNotEmpty() } voice.cancel() awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE } delay(50) assertTrue(rec.finals.isEmpty(), "после cancel финал не отдаётся: ${rec.finals}") assertEquals(VoiceState.IDLE, voice.state) } @Test fun `microphone ending on its own finishes the turn`() = runBlocking { val rec = Recorder() val recognizer = FakeRecognizer(finalText = "конец") val voice = controller(this, finiteMic(chunk, chunk), recognizer, rec) voice.start() awaitWithDump("финал отдан без stop()", rec, voice) { rec.finals.isNotEmpty() } assertEquals(listOf("конец"), rec.finals) assertEquals(VoiceState.IDLE, voice.state) assertTrue(recognizer.closed) } @Test fun `microphone chunks reach the recognizer`() = runBlocking { val rec = Recorder() val recognizer = FakeRecognizer(finalText = "ок") val voice = controller(this, finiteMic(chunk, chunk, chunk), recognizer, rec) voice.start() awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() } assertEquals(3, recognizer.fed.size) assertEquals(chunk.toList(), recognizer.fed[0].toList()) } @Test fun `empty chunks are skipped`() = runBlocking { val rec = Recorder() val recognizer = FakeRecognizer(finalText = "ок") val voice = controller(this, finiteMic(ByteArray(0), chunk, ByteArray(0)), recognizer, rec) voice.start() awaitWithDump("финал отдан", rec, voice) { rec.finals.isNotEmpty() } assertEquals(1, recognizer.fed.size) } @Test fun `silence is not turned into text`() = runBlocking { val rec = Recorder() val recognizer = FakeRecognizer(finalText = "", speechDetected = false) val voice = controller(this, finiteMic(chunk, chunk), recognizer, rec) voice.start() awaitWithDump("ошибка про тишину", rec, voice) { rec.errors.isNotEmpty() } assertEquals(listOf("Речь не распознана"), rec.errors) assertTrue(rec.finals.isEmpty()) assertEquals(VoiceState.IDLE, voice.state) } @Test fun `empty final with speech detected is silently dropped`() = runBlocking { val rec = Recorder() val recognizer = FakeRecognizer(finalText = " ", speechDetected = true) val voice = controller(this, finiteMic(chunk), recognizer, rec) voice.start() awaitWithDump("вернулись в простой", rec, voice) { voice.state == VoiceState.IDLE && recognizer.finished } delay(50) assertTrue(rec.finals.isEmpty()) assertTrue(rec.errors.isEmpty(), "речь была, но текста нет — это не ошибка: ${rec.errors}") } @Test fun `microphone failure surfaces as error`() = runBlocking { val rec = Recorder() val recognizer = FakeRecognizer() val mic = MicSource { _, _ -> flow { throw IllegalStateException("no line") } } val voice = controller(this, mic, recognizer, rec) voice.start() awaitWithDump("ошибка микрофона", rec, voice) { rec.errors.isNotEmpty() } assertEquals(listOf("Микрофон недоступен: no line"), rec.errors) assertEquals(VoiceState.IDLE, voice.state) assertTrue(recognizer.closed, "распознаватель закрывается и при ошибке микрофона") } @Test fun `recognizer creation failure surfaces as error`() = runBlocking { val rec = Recorder() val voice = VoiceController( scope = this, recognizers = VoiceRecognizerFactory { throw IllegalStateException("model not found") }, mic = finiteMic(chunk), onState = { rec.states += it }, onError = { rec.errors += it }, ) voice.start() await("ошибка создания") { rec.errors.isNotEmpty() } assertEquals(listOf("Не удалось запустить распознавание: model not found"), rec.errors) assertEquals(VoiceState.IDLE, voice.state) } @Test fun `toggle starts then stops`() = runBlocking { val rec = Recorder() val recognizer = FakeRecognizer(finalText = "готово") val voice = controller(this, endlessMic(), recognizer, rec) voice.toggle() await("пишем") { voice.state == VoiceState.RECORDING } assertTrue(voice.isActive) voice.toggle() awaitWithDump("финал", rec, voice) { rec.finals.isNotEmpty() } assertTrue(rec.finals.contains("готово")) } @Test fun `stop before start does nothing`() = runBlocking { val rec = Recorder() val voice = controller(this, endlessMic(), FakeRecognizer(), rec) voice.stop() voice.cancel() delay(30) assertEquals(VoiceState.IDLE, voice.state) assertTrue(rec.states.isEmpty(), "никаких переходов не ожидается: ${rec.states}") } /** Ожидание условия с реальным временем (см. заметку про ksqlite/Dispatchers). */ private suspend fun await(what: String, timeoutMs: Long = 5_000, condition: () -> Boolean) { val deadline = System.currentTimeMillis() + timeoutMs while (System.currentTimeMillis() < deadline) { if (condition()) return delay(5) } fail("не дождались: $what") } /** То же, но с дампом состояния — чтобы падение было диагностируемым. */ private suspend fun awaitWithDump(what: String, rec: Recorder, voice: VoiceController, condition: () -> Boolean) { val deadline = System.currentTimeMillis() + 5_000 while (System.currentTimeMillis() < deadline) { if (condition()) return delay(5) } fail( "не дождались: $what; state=${voice.state} active=${voice.isActive} " + "states=${rec.states} partials=${rec.partials} finals=${rec.finals} errors=${rec.errors}" ) } }