Files
view-mate/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt
T

260 lines
11 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.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<GlassesServer, Int> {
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>(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<Unit>(2)
val received = Channel<HostToGlasses>(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<SetMode>(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<SttCancel>(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<SttCancel>(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<SttDone>(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)
}
}
}