2c71542b13
- 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 и найденные/починенные баги
278 lines
10 KiB
Kotlin
278 lines
10 KiB
Kotlin
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}"
|
||
)
|
||
}
|
||
}
|