package pw.binom.viewmate.phone import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.webSocket import io.ktor.websocket.Frame import io.ktor.websocket.readText import io.ktor.websocket.send import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import pw.binom.viewmate.core.GlassesMode import pw.binom.viewmate.core.phone.PhoneState import pw.binom.viewmate.core.protocol.DownloadPhase import pw.binom.viewmate.core.protocol.DownloadProgress import pw.binom.viewmate.core.protocol.Gesture import pw.binom.viewmate.core.protocol.GlassesStatus import pw.binom.viewmate.core.protocol.GlassesToHost import pw.binom.viewmate.core.protocol.Hello import pw.binom.viewmate.core.protocol.HostToGlasses import pw.binom.viewmate.core.protocol.SetMode import pw.binom.viewmate.core.protocol.StopStt import pw.binom.viewmate.core.protocol.SttAudio import pw.binom.viewmate.core.protocol.SttCancel import pw.binom.viewmate.core.protocol.SttDone import pw.binom.viewmate.core.protocol.Welcome import pw.binom.viewmate.core.protocol.protocolJson import pw.binom.viewmate.phone.stt.FakePhraseRecognizer import pw.binom.viewmate.phone.stt.SileroStreamingVad import pw.binom.viewmate.phone.stt.SttStreamer import pw.binom.viewmate.phone.stt.WavPcm import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertTrue import kotlin.test.fail /** * Интеграционные тесты хаба поверх реального [WifiServerTransport] (Ktor CIO): * поднимаем сервер на свободном порту, ходим WS-клиентом, проверяем * handshake/жесты/бродкаст/статусы (TASK-transport.md п.5: «тесты зелёные»). */ class GlassesServerTest { private val json = protocolJson /** Поднять [GlassesServer] на случайном порту и вернуть его + порт. */ private fun startServer(): Pair { val port = freePort() val server = GlassesServer(port = port, state = PhoneState(), useBluetooth = false) server.start() return server to port } private fun freePort(): Int { val s = java.net.ServerSocket(0) val p = s.localPort s.close() return p } @Test fun helloGetsWelcome() = runServer { port, hub -> val client = wsClient() client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0"))) val frame = incoming.receive() as Frame.Text val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText()) assertIs(welcome) assertEquals(GlassesMode.MOVIE, welcome.mode) } } @Test fun gestureIsProcessed() = runServer { port, hub -> val client = wsClient() client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick"))) send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0))) } awaitTrue { hub.gesturesReceived.value == 1 } awaitTrue { hub.connected.value == 0 } } @Test fun connectedCounterTracksSessions() = runServer { port, hub -> val client = wsClient() client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { } awaitTrue { hub.connected.value == 0 } } @Test fun broadcastReachesAllGlasses() = runServer { port, hub -> val client = wsClient() coroutineScope { val opened = Channel(2) val received = Channel(2) val jobs = (1..2).map { launch { client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { opened.send(Unit) while (true) { val frame = incoming.receive() if (frame is Frame.Text) { received.send(json.decodeFromString(HostToGlasses.serializer(), frame.readText())) } } } } } repeat(2) { opened.receive() } awaitTrue { hub.connected.value == 2 } hub.broadcast(SetMode(GlassesMode.CHAT)) val first = received.receive() val second = received.receive() listOf(first, second).forEach { assertIs(it) assertEquals(GlassesMode.CHAT, it.mode) } jobs.forEach { it.cancelAndJoin() } awaitTrue { hub.connected.value == 0 } } } @Test fun downloadProgressUpdatesHubPerFileMap() = runServer { port, hub -> val client = wsClient() client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING))) send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE))) } awaitTrue { hub.glassesDownloads.value["i1"]?.size == 2 } assertEquals(45, hub.glassesDownloads.value["i1"]?.get("video.mkv")?.percent) assertEquals(DownloadPhase.DONE, hub.glassesDownloads.value["i1"]?.get("audio-0.ogg")?.phase) } @Test fun extendedGlassesStatusUpdatesHubFields() = runServer { port, hub -> val client = wsClient() client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { send( json.encodeToString( GlassesToHost.serializer(), GlassesStatus( batteryPercent = 60, storageUsedGb = 12.5, storageTotalGb = 32.0, storageTotalBytes = 34_359_738_368, storageFreeBytes = 20_971_520_000, mediaBytes = 1_863_946_240, downloadedItemIds = listOf("i1", "i2"), mediaDir = "/data/glasses/files/viewmate", appVersion = "0.2.0", ), ), ) } awaitTrue { hub.downloadedItemIds.value.contains("i1") } assertEquals(34_359_738_368L, hub.storageTotalBytes.value) assertEquals(20_971_520_000L, hub.storageFreeBytes.value) assertEquals(1_863_946_240L, hub.mediaBytes.value) assertEquals("/data/glasses/files/viewmate", hub.mediaDir.value) assertEquals("0.2.0", hub.appVersion.value) } // ---------- StopStt → SttCancel/SttDone (TASK-cancel-listen.md) ---------- /** StopStt(cancel=true) — двойной тап-отмена на очках: очки получают SttCancel(reason=cancel). */ @Test fun stopSttCancelSendsSttCancelReasonCancel() = runServer { port, hub -> val client = wsClient() client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = true))) val frame = incoming.receive() as Frame.Text val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText()) val cancel = assertIs(msg) assertEquals(SttCancel.REASON_CANCEL, cancel.reason) } } /** StopStt(cancel=false) с пустым текстом — очки возвращаются в «слушаю»: SttCancel(reason=user). */ @Test fun stopSttEmptyTextBroadcastsSttCancelReasonUser() = runServer { port, hub -> val client = wsClient() client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false))) val frame = incoming.receive() as Frame.Text val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText()) val cancel = assertIs(msg) assertEquals(SttCancel.REASON_USER, cancel.reason) } } /** * Полная фраза (StopStt(cancel=false) с текстом → SttDone): реальный Silero VAD * (SileroStreamingVad, JVM/ONNX) сегментирует фикстуру, фейк-распознаватель * даёт «привет» на каждый сегмент → очки получают SttDone с непустым текстом. */ @Test fun stopSttFullTextSendsSttDone() = runServer { port, hub -> hub.sttFactory = { SttStreamer( stt = FakePhraseRecognizer(), vad = SileroStreamingVad(), onPhrase = { _, _ -> }, onSilence30s = { }, ) } val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath) val client = wsClient() client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") { // кидать по 3200 байт (100 мс @ 16 кГц s16le mono) var off = 0 while (off < pcm.size) { val end = minOf(off + 3200, pcm.size) send(json.encodeToString(GlassesToHost.serializer(), SttAudio(pcm.copyOfRange(off, end)))) off = end } send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false))) val frame = incoming.receive() as Frame.Text val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText()) val done = assertIs(msg) assertTrue(done.full.isNotBlank()) } } // ---------- помощники ---------- private inline fun runServer(crossinline block: suspend (port: Int, hub: GlassesHub) -> Unit) { val (server, port) = startServer() try { kotlinx.coroutines.runBlocking { block(port, server.hub) } } finally { server.stop() } } private fun wsClient() = io.ktor.client.HttpClient { install(WebSockets) } private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) { val deadline = System.currentTimeMillis() + timeoutMs while (!condition()) { if (System.currentTimeMillis() > deadline) fail("условие не выполнено за ${timeoutMs}мс") delay(20) } } }