Files
agentik-desktop/src/jvmTest/kotlin/pw/binom/agentik/desktop/voice/VoiceControllerTest.kt
T
subochev 2c71542b13 Десктоп-клиент: Compose-UI, сессии, SQLite-кэш, голос, живые тесты
- settings/: settings.json (агенты, тема, clientId), атомарная запись
- persistence/: ConversationMetaRepository — группы, lastSeen, превью,
  watermark синхронизации (synced_at через PRAGMA table_info)
- session/: AgentRegistry (параллельное подключение, деградация в офлайн),
  AgentConnection (журнал + мета + живой список диалогов conversationsFlow,
  refresh по SSE-потоку), ChatSession (history + backfill + live + preview,
  дедуп по id, 1 мс шаг watermark, syncMutex), LiveTurn
- UI: рейка агентов, список диалогов (живое обновление), чат со стримингом,
  композер (Enter отправляет, Shift+Enter переносит, Ctrl+M — голос),
  окна «Агенты»/«Группы»/«Новый диалог», тёмная и светлая темы
- voice/: mic-api → Silero VAD → Vosk на одном выделенном потоке;
  VoiceNative.warmUp() до skia (иначе SIGSEGV), модель в артефакте
- markdown/: вендоренный рендер из ai/assistent
- ошибки в баннер через AppState.reportError (CancellationException игнорируется)
- тесты 103 (реальная SQLite + фейковый транспорт); против живого агента —
  RealServerIT, RealAppStateIT, RealSpeechIT
- LIVE-RUN-NOTES.md: живой прогон в GUI и найденные/починенные баги
2026-09-28 10:08:10 +03:00

278 lines
10 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> = emptyList(),
private val finalText: String = "",
override val speechDetected: Boolean = true,
) : VoiceRecognizer {
val fed = mutableListOf<ByteArray>()
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<VoiceState>()
val partials = mutableListOf<String>()
val finals = mutableListOf<String>()
val errors = mutableListOf<String>()
}
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<ByteArray> { 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}"
)
}
}