Ассистент Порфирий: тула времени, сессии, чат-UI, голосовой цикл; BT-транспорт очки↔телефон
- 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) — зелёные
This commit is contained in:
@@ -2,7 +2,6 @@ package pw.binom.viewmate.phone
|
||||
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.plugins.websocket.webSocket
|
||||
import io.ktor.server.testing.testApplication
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import io.ktor.websocket.send
|
||||
@@ -30,17 +29,34 @@ 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
|
||||
|
||||
@Test
|
||||
fun helloGetsWelcome() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
/** Поднять [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
|
||||
}
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
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())
|
||||
@@ -50,12 +66,9 @@ class GlassesServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gestureIsProcessed() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
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)))
|
||||
}
|
||||
@@ -64,28 +77,22 @@ class GlassesServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectedCounterTracksSessions() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) { }
|
||||
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() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
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(GLASSES_WS_PATH) {
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||
opened.send(Unit)
|
||||
while (true) {
|
||||
val frame = incoming.receive()
|
||||
@@ -115,12 +122,9 @@ class GlassesServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadProgressUpdatesHubPerFileMap() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
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)))
|
||||
}
|
||||
@@ -131,12 +135,9 @@ class GlassesServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extendedGlassesStatusUpdatesHubFields() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
fun extendedGlassesStatusUpdatesHubFields() = runServer { port, hub ->
|
||||
val client = wsClient()
|
||||
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||
send(
|
||||
json.encodeToString(
|
||||
GlassesToHost.serializer(),
|
||||
@@ -163,6 +164,21 @@ class GlassesServerTest {
|
||||
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()) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Тесты тулов ассистента — чистая логика без БД и Android.
|
||||
*/
|
||||
class AgentToolsTest {
|
||||
|
||||
@Test
|
||||
fun `тул времени возвращает ISO 8601 с датой и временем`() {
|
||||
val result = TimeTool.invoke("{}")
|
||||
// 2026-08-22T03:14:05+03:00 — содержит дату, время и смещение
|
||||
assertTrue(
|
||||
result.matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}""")),
|
||||
"не ISO 8601: $result",
|
||||
)
|
||||
assertTrue(result.startsWith("20"), "год не 20xx: $result")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `реестр содержит тулу времени с описанием`() {
|
||||
assertTrue(AgentTools.ALL.any { it.name == "get_current_time" })
|
||||
val desc = AgentTools.describeAll()
|
||||
assertTrue(desc.contains("get_current_time"))
|
||||
assertTrue(desc.contains("ISO 8601"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `тула времени не зависит от аргументов`() {
|
||||
// Пустой JSON и мусор — один результат (тула без параметров)
|
||||
val a = TimeTool.invoke("{}")
|
||||
val b = TimeTool.invoke("{\"x\":1}")
|
||||
assertEquals(a.take(19), b.take(19)) // одинаковые дата и время в пределах секунды
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user