225 lines
9.3 KiB
Kotlin
225 lines
9.3 KiB
Kotlin
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.SttCancel
|
||
import pw.binom.viewmate.core.protocol.Welcome
|
||
import pw.binom.viewmate.core.protocol.protocolJson
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
import kotlin.test.assertIs
|
||
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 + onStopFullText) в JVM-юните
|
||
// не проверяется: реальный SttStreamer держит нативный VAD (com.k2fsa.sherpa.onnx.Vad),
|
||
// а нативная библиотека sherpa-onnx есть ТОЛЬКО под Android-bionic — в JVM-юните её нет,
|
||
// и фейк падает UnsatisfiedLinkError (неремонтируемо). Сценарий покрывается на устройстве.
|
||
|
||
// ---------- помощники ----------
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|