bbf7a020d4
- BT (RFCOMM SPP 00001105) вместо WiFi: BtGlassesTransport/BtServerTransport, реконнект 3с - PlaybackService (foreground, WAKE_LOCK) против морозов HONOR - Ассистент: AgentTools (TimeTool ISO 8601), TOOL_CALL-протокол, сессии SQLite (UUID) - Чат-UI: создание/сброс/переключение сессий, автообновление ленты 2с - LlmClient на прямом OkHttp (Koog давал пустой content на длинных историях) - Голосовой цикл: тройной тап на очках → STT → активная сессия → ShowText - Тесты: AgentToolsTest (3), GlassesServerTest переписан (6) — зелёные
190 lines
7.3 KiB
Kotlin
190 lines
7.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.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)
|
|
}
|
|
|
|
// ---------- помощники ----------
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|