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.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 { 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) } // ---------- помощники ---------- 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) } } }