feat: сетевая часть для app-phone (WS-сервер) и app-glasses (WS-клиент), PhoneState/PhoneActions → lib-core

lib-core:
- core/phone/: PhoneState, PhoneActions, GlassesSender (интерфейс) — перенесены из app-host
- core/net/: GlassesWsClient — KMP WS-клиент очков (реконнект, Hello, send GlassesToHost)
- GlassesWsClientTest + PhoneActionsTest (перенесён)

app-phone (Android):
- GlassesServer: Ktor WS-сервер (CIO) 0.0.0.0:8080/ws/glasses, принимает очки, Welcome из стейта
- PhoneApp (Application): клиенты, стейт, действия, сервер
- PhoneConfig: креды Jellyfin/Mirror
- MainActivity: статус сервера (порт, счётчик подключений)
- GlassesServerTest: hello→welcome, gesture

app-glasses (Android):
- GlassesApp (Application), HostConnection: WS-клиент к телефону, Hello, реконнект, обработка HostToGlasses (PlayVideo/SetMode и т.д.), StateFlow для UI
- MainActivity: статус подключения

app-host: PhoneActions/PhoneState удалены (теперь lib-core), адаптирован под GlassesSender

171 тест, 0 провалов; app-host жив (catalog/watch/status работают через lib-core)
This commit is contained in:
Hermes Agent
2026-08-11 15:06:07 +03:00
parent f0f1f71575
commit 298d8e0b69
27 changed files with 1165 additions and 118 deletions
@@ -0,0 +1,6 @@
package pw.binom.viewmate.core
/** Единый лог общего слоя (KMP): без java.time, просто тег + сообщение. */
fun log(tag: String, message: String) {
println("[view-mate] $tag $message")
}
@@ -6,11 +6,13 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import pw.binom.viewmate.core.protocol.protocolJson
/**
* Общий HTTP-клиент для media-бэкендов (Jellyfin, media-mirror):
@@ -45,6 +47,31 @@ object HttpClientDebugLogging {
var enabled: Boolean = true
}
/**
* HTTP-клиент для WS-протокола очков (GlassesWsClient):
* JSON-сериализация kotlinx с дискриминатором "type", таймауты, поддержка WS.
*/
fun defaultWsClient(): HttpClient = HttpClient {
install(ContentNegotiation) {
json(protocolJson)
}
install(HttpTimeout) {
connectTimeoutMillis = 5_000
requestTimeoutMillis = 30_000
}
install(WebSockets) {
pingIntervalMillis = 15_000
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
if (HttpClientDebugLogging.enabled) println("[view-mate] $message")
}
}
level = LogLevel.INFO
}
}
/** Ошибка HTTP-запроса с понятным текстом. */
class ApiClientException(
message: String,
@@ -0,0 +1,86 @@
package pw.binom.viewmate.core.net
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.media.defaultWsClient
import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.protocolJson
/**
* WS-клиент очков: подключается к хосту (телефону), шлёт [GlassesToHost],
* принимает [HostToGlasses]. [connect] — бесконечный цикл приёма с
* реконнектом через 3с при обрыве.
*/
class GlassesWsClient(
private val url: String, // ws://<host>:8080/ws/glasses
private val http: HttpClient = defaultWsClient(),
) {
@Volatile
private var closed = false
@Volatile
private var session: DefaultClientWebSocketSession? = null
/**
* Подключиться и слушать. Возвращается при вызове [close].
* [onHostMessage] — входящие от хоста; [onConnected]/[onDisconnected] —
* события (пере)подключения (для Hello и смены статуса в UI).
*/
suspend fun connect(
onHostMessage: suspend (HostToGlasses) -> Unit,
onConnected: suspend () -> Unit = {},
onDisconnected: suspend () -> Unit = {},
) {
while (!closed) {
try {
val ws = http.webSocketSession(url)
session = ws
onConnected()
try {
for (frame in ws.incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
val msg = protocolJson.decodeFromString(HostToGlasses.serializer(), text)
onHostMessage(msg)
}
}
} finally {
session = null
onDisconnected()
ws.close()
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("ws-client", "соединение оборвано (${e.message}), реконнект через 3с")
}
if (closed) break
delay(3_000)
}
}
/** Отправить сообщение хосту. Без активного соединения — no-op. */
suspend fun send(msg: GlassesToHost) {
val text = protocolJson.encodeToString(GlassesToHost.serializer(), msg)
session?.send(text)
}
/** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */
fun close() {
closed = true
session?.cancel()
}
}
@@ -0,0 +1,8 @@
package pw.binom.viewmate.core.phone
import pw.binom.viewmate.core.protocol.HostToGlasses
/** Куда слать сообщения очкам (реализуется WS-хабом телефона/хоста). */
interface GlassesSender {
suspend fun send(msg: HostToGlasses)
}
@@ -0,0 +1,219 @@
package pw.binom.viewmate.core.phone
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorClient
import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.core.media.MirrorStatus
import pw.binom.viewmate.core.protocol.AssistantState
import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ChatSessionMsg
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.ShowText
/**
* Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки.
* Каждый метод возвращает человекочитаемый текст для консоли/UI
* и дёргает [GlassesSender] для реальных команд очкам.
*/
class PhoneActions(
private val jellyfin: JellyfinClient,
private val mirror: MirrorClient,
private val sender: GlassesSender,
private val state: PhoneState,
private val userId: String, // первый пользователь Jellyfin
) {
// --- каталог ---
/** Корень Jellyfin или содержимое папки, с бейджами статусов зеркал. */
suspend fun catalog(parentId: String? = null): String {
val items = if (parentId == null) jellyfin.rootItems(userId) else jellyfin.items(userId, parentId)
val mirrors = mirror.list(limit = 500).associateBy { it.itemId }
val rows = items.map { item -> catalogLine(item, mirrors[item.Id]) }
val width = rows.maxOfOrNull { it.first.length } ?: 0
val sb = StringBuilder()
sb.appendLine(if (parentId == null) "Каталог Jellyfin (корень):" else "Каталог Jellyfin (папка $parentId):")
rows.forEach { (prefix, badge) ->
sb.appendLine(" ${prefix.padEnd(width)} $badge")
}
return sb.toString().trimEnd()
}
private fun catalogLine(item: JellyfinItem, job: MirrorJob?): Pair<String, String> {
val prefix = "${typeLabel(item)} ${item.Name ?: item.Id} (${item.Id})"
val badge = when {
job == null -> "нет"
job.status == "done" -> "✓"
job.status == "processing" || job.status == "queued" -> "⟳ ${job.progress ?: 0}%"
else -> job.status ?: "нет"
}
return prefix to badge
}
private fun typeLabel(item: JellyfinItem): String = when (item.Type) {
"Folder", "CollectionFolder" -> "[Папка]"
"Movie" -> "[Фильм]"
"Series" -> "[Сериал]"
"Episode" -> "[Эпизод]"
else -> "[${item.Type ?: "Элемент"}]"
}
// --- открытие элемента ---
/** Инфо об элементе + статус зеркала + дорожки озвучки. */
suspend fun open(itemId: String): String {
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId)
val sb = StringBuilder()
sb.appendLine("${typeLabel(item)} ${item.Name ?: itemId} (${item.Id})")
item.Overview?.let { sb.appendLine("Описание: ${it.take(200)}") }
item.RunTimeTicks?.let { sb.appendLine("Длительность: ${ticksToMinutes(it)} мин") }
sb.append(mirrorStatusText(status))
return sb.toString().trimEnd()
}
// --- включить на очках ---
/** Включить на очках: зеркало должно быть done → PlayVideo с audioIndex. */
suspend fun watch(itemId: String, audioIndex: Int = 0): String {
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId)
val files = status?.files
if (status == null || status.status != "done" || files == null) {
return "зеркало для «${item.Name ?: itemId}» не готово (${status?.status ?: "нет"}). Закажите: mirror $itemId"
}
val title = item.Name ?: itemId
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = files.video.url, audioIndex = audioIndex))
state.mode = GlassesMode.MOVIE
state.currentItem = item
state.currentAudioIndex = audioIndex
state.mirrorStatus = status
state.playing = true
return "включил «$title» на очках (озвучка #$audioIndex): ${files.video.url}"
}
// --- зеркала ---
/** Заказать конвертацию. */
suspend fun makeMirror(itemId: String): String {
val job = mirror.createMirror(itemId)
return "заказана конвертация $itemId: id=${job.id}, статус=${job.status}"
}
/** Статус зеркала по itemId. */
suspend fun mirrorStatus(itemId: String): String {
val status = mirror.mirrorByItem(itemId)
return mirrorStatusText(status)
}
private fun mirrorStatusText(status: MirrorStatus?): String {
if (status == null) return "Зеркало: нет (закажите: mirror <id>)"
val lines = mutableListOf<String>()
lines += "Зеркало: ${status.status}${if (status.status == "done") " ✓" else ""}"
val files = status.files
if (files != null) {
lines += " Видео: ${files.video.url}"
if (files.audios.isEmpty()) {
lines += " Дорожки: нет"
} else {
lines += " Дорожки:"
files.audios.forEach { a ->
lines += " [${a.index}] ${a.title ?: a.language ?: "без названия"} → ${a.url}"
}
}
}
return lines.joinToString("\n")
}
// --- скачивание (эмуляция) ---
/** Скачать на очки/телефон (эмуляция: отметить в стейте + лог). */
fun download(itemId: String, target: String): String {
state.downloaded[itemId] = target
log("phone", "скачано $itemId → $target (эмуляция)")
return "скачано $itemId → $target (эмуляция, отмечено в стейте)"
}
/** Удалить скачанное (эмуляция). */
fun deleteDownloaded(itemId: String): String {
val removed = state.downloaded.remove(itemId)
return if (removed != null) "удалено скачанное: $itemId ($removed)"
else "не было скачано: $itemId"
}
// --- сессии ---
fun sessions(): String {
val sb = StringBuilder("Сессии:")
state.sessions.forEach { s ->
val marker = if (s.id == state.activeSessionId) " ◄ активна" else ""
sb.appendLine(" ${s.id} — ${s.title}$marker")
}
return sb.toString().trimEnd()
}
/** Переключить сессию и разослать ChatSessionMsg очкам. */
suspend fun selectSession(id: String): String {
val session = state.sessions.firstOrNull { it.id == id }
?: return "сессия «$id» не найдена. Доступны: ${state.sessions.joinToString(", ") { it.id }}"
state.activeSessionId = id
sender.send(ChatSessionMsg(sessionId = id, title = session.title))
return "выбрана сессия: ${session.title} (${session.id})"
}
// --- состояние ---
/** Что сейчас на очках/в телефоне. */
fun status(): String {
val sb = StringBuilder()
sb.appendLine("Режим: ${state.mode}")
sb.appendLine("Играет: ${state.currentItem?.Name ?: state.currentItem?.Id ?: "ничего"} " +
"(${state.currentItem?.Id ?: "-"})")
sb.appendLine("Озвучка: #${state.currentAudioIndex}")
sb.appendLine("Воспроизведение: ${if (state.playing) "играет" else "пауза"}")
sb.appendLine("Зеркало: ${state.mirrorStatus?.status ?: "нет"}")
sb.appendLine("Сессия: ${state.activeSessionId ?: "нет"}")
val dl = state.downloaded.entries.joinToString("; ") { "${it.key} → ${it.value}" }
sb.appendLine("Скачано: ${dl.ifEmpty { "ничего" }}")
return sb.toString().trimEnd()
}
// --- прямые команды очкам (шлются через GlassesSender) ---
/** Переключить режим очков: SetMode + обновить стейт. */
suspend fun applyMode(mode: GlassesMode): String {
state.mode = mode
sender.send(SetMode(mode))
return "режим → $mode"
}
/** Состояние ассистента: AssistantStateMsg на очки. */
suspend fun applyAssistantState(assistantState: AssistantState, sec: Int): String {
sender.send(AssistantStateMsg(state = assistantState, recordingSeconds = sec))
return "ассистент → $assistantState (${sec}с)"
}
/** Показать текст на очках: ShowText. */
suspend fun applyText(text: String): String {
sender.send(ShowText(text = text))
return "показан текст: $text"
}
/** Команда медиа: MediaCommandMsg на очки. */
suspend fun applyMediaCommand(cmd: MediaCommand, seekMs: Long = 0): String {
sender.send(MediaCommandMsg(command = cmd, seekMs = seekMs))
return "команда медиа: $cmd${if (seekMs != 0L) " ($seekMs мс)" else ""}"
}
private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60
}
@@ -0,0 +1,71 @@
package pw.binom.viewmate.core.phone
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorStatus
/** Сессия чата (эмуляция) — простой DTO. */
data class SessionInfo(
val id: String,
val title: String,
)
/**
* Единый стейт «телефона» (как у будущего приложения).
* Изменение любого поля логируется в консоль.
*/
class PhoneState {
@Volatile
var mode: GlassesMode = GlassesMode.MOVIE
set(value) {
field = value
log("state", "режим → $value")
}
@Volatile
var currentItem: JellyfinItem? = null
set(value) {
field = value
log("state", "текущий → ${value?.Name ?: value?.Id ?: "ничего"}")
}
@Volatile
var currentAudioIndex: Int = 0
set(value) {
field = value
log("state", "озвучка → #$value")
}
@Volatile
var mirrorStatus: MirrorStatus? = null
set(value) {
field = value
log("state", "зеркало → ${value?.status ?: "нет"}")
}
@Volatile
var activeSessionId: String? = null
set(value) {
field = value
log("state", "сессия → $value")
}
@Volatile
var playing: Boolean = false
set(value) {
field = value
log("state", if (value) "воспроизведение" else "пауза")
}
/** Эмуляция сессий чата: 3 фейковых + текущая. */
val sessions: MutableList<SessionInfo> = mutableListOf(
SessionInfo("glasses-movie", "Очки/кино"),
SessionInfo("work", "Работа"),
SessionInfo("general", "Общее"),
)
/** Эмуляция скачанного: itemId → куда (очки|телефон). */
val downloaded: MutableMap<String, String> = mutableMapOf()
}
@@ -0,0 +1,13 @@
package pw.binom.viewmate.core.protocol
import kotlinx.serialization.json.Json
/**
* Общий Json для WS-протокола: полиморфизм через дискриминатор "type"
* (как описано в TASK.md и используется в app-host).
*/
val protocolJson: Json = Json {
classDiscriminator = "type"
ignoreUnknownKeys = true
encodeDefaults = true
}
@@ -0,0 +1,319 @@
package pw.binom.viewmate.core.phone
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.MockRequestHandleScope
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.HttpRequestData
import io.ktor.client.request.HttpResponseData
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.TextContent
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import pw.binom.viewmate.core.media.CreateMirrorRequest
import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.MirrorClient
import pw.binom.viewmate.core.protocol.ChatSessionMsg
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.PlayVideo
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PhoneActionsTest {
private val json = Json { ignoreUnknownKeys = true }
private val rootItemsJson = """
{"Items":[
{"Id":"f1","Name":"Фильмы","Type":"CollectionFolder"},
{"Id":"m1","Name":"Интерстеллар","Type":"Movie"},
{"Id":"m2","Name":"Дюна","Type":"Movie"}
]}
""".trimIndent()
private fun MockRequestHandleScope.jsonResponse(body: String) = respond(
content = body,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
private fun mockHttp(
handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData,
): HttpClient = HttpClient(MockEngine { request -> handler(request) }) {
install(ContentNegotiation) { json(json) }
}
private fun jellyfin(handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData): JellyfinClient =
JellyfinClient(
baseUrl = "https://jellyfin.binom.pw",
apiKey = "tok",
http = mockHttp(handler),
)
private fun mirror(handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData): MirrorClient =
MirrorClient(
baseUrl = "https://mirror.binom.pw",
apiKey = "secret",
http = mockHttp(handler),
)
private fun simpleJellyfin(): JellyfinClient = jellyfin { request ->
when (request.url.encodedPath) {
"/Users/u1/Items" -> jsonResponse(rootItemsJson)
else -> jsonResponse("""{"Id":"m1","Name":"Интерстеллар","Type":"Movie","Overview":"Лучший фильм","RunTimeTicks":148800000000}""")
}
}
private fun doneMirror(): MirrorClient = mirror { request ->
when (request.url.encodedPath) {
"/api/mirror/by-item/m1" -> jsonResponse(
"""{"itemId":"m1","status":"done",""" +
""""files":{"video":{"key":"v1","url":"https://s3/media/m1/video.mp4"},""" +
""""audios":[{"index":1,"key":"a1","title":"Русский","language":"ru","url":"https://s3/m1/a1.ogg"},{"index":2,"key":"a2","url":"https://s3/m1/a2.ogg"}]}}"""
)
else -> jsonResponse("""[]""")
}
}
private class SenderStub(val phoneState: PhoneState = PhoneState()) : GlassesSender {
val sent = mutableListOf<HostToGlasses>()
override suspend fun send(msg: HostToGlasses) {
sent += msg
}
}
private fun actions(
jf: JellyfinClient,
mirror: MirrorClient,
sender: SenderStub,
): PhoneActions = PhoneActions(
jellyfin = jf,
mirror = mirror,
sender = sender,
state = sender.phoneState,
userId = "u1",
)
@Test
fun catalogShowsFoldersWithMirrorBadges() = runTest {
val jf = simpleJellyfin()
val mirrorClient = mirror { request ->
jsonResponse(
"""[{"id":"job1","itemId":"m1","status":"done","progress":100},""" +
"""{"id":"job2","itemId":"m2","status":"processing","progress":45}]"""
)
}
val out = actions(jf, mirrorClient, SenderStub()).catalog()
assertTrue(out.contains("Каталог Jellyfin (корень)"))
assertTrue(out.contains("Фильмы (f1)"))
assertTrue(out.contains("Интерстеллар (m1)"))
assertTrue(out.contains("Дюна (m2)"))
assertTrue(out.contains("✓"))
assertTrue(out.contains("45%"))
assertTrue(out.contains("нет"))
}
@Test
fun catalogParentShowsFolderContents() = runTest {
val jf = jellyfin { request ->
when (request.url.encodedPath) {
"/Users/u1/Items" -> {
assertEquals("f1", request.url.parameters["ParentId"])
jsonResponse("""{"Items":[{"Id":"e1","Name":"Пилот","Type":"Episode","SeriesName":"Друзья"}]}""")
}
else -> jsonResponse("""{"Items":[]}""")
}
}
val mirrorClient = mirror { request -> jsonResponse("""[]""") }
val out = actions(jf, mirrorClient, SenderStub()).catalog("f1")
assertTrue(out.contains("папка f1"))
assertTrue(out.contains("Пилот (e1)"))
assertTrue(out.contains("нет"))
}
@Test
fun openShowsInfoMirrorAndTracks() = runTest {
val out = actions(simpleJellyfin(), doneMirror(), SenderStub()).open("m1")
assertTrue(out.contains("Интерстеллар (m1)"))
assertTrue(out.contains("Лучший фильм"))
assertTrue(out.contains("Зеркало: done ✓"))
assertTrue(out.contains("https://s3/media/m1/video.mp4"))
assertTrue(out.contains("[1] Русский"))
assertTrue(out.contains("https://s3/m1/a1.ogg"))
assertTrue(out.contains("[2]"))
}
@Test
fun watchSendsPlayVideoWithUrlAndAudioIndex() = runTest {
val sender = SenderStub()
val out = actions(simpleJellyfin(), doneMirror(), sender).watch("m1", audioIndex = 1)
assertTrue(out.contains("включил «Интерстеллар»"))
assertEquals(1, sender.sent.size)
val msg = sender.sent.single()
assertIs<PlayVideo>(msg)
assertEquals("m1", msg.itemId)
assertEquals("Интерстеллар", msg.title)
assertEquals("https://s3/media/m1/video.mp4", msg.videoUrl)
assertEquals(1, msg.audioIndex)
assertEquals("m1", sender.phoneState.currentItem?.Id)
assertEquals(1, sender.phoneState.currentAudioIndex)
assertTrue(sender.phoneState.playing)
assertEquals("done", sender.phoneState.mirrorStatus?.status)
}
@Test
fun watchWhenMirrorNotReadyReturnsHint() = runTest {
val sender = SenderStub()
val mirrorClient = mirror { request ->
respond("", HttpStatusCode.NotFound)
}
val out = actions(simpleJellyfin(), mirrorClient, sender).watch("m1")
assertTrue(out.contains("не готово"))
assertTrue(sender.sent.isEmpty())
}
@Test
fun watchUnknownItemReturnsNotFound() = runTest {
val sender = SenderStub()
val jf = jellyfin { request ->
when (request.url.encodedPath) {
"/Users/u1/Items/missing" -> respond("", HttpStatusCode.NotFound)
else -> jsonResponse("""{"Items":[]}""")
}
}
val out = actions(jf, doneMirror(), sender).watch("missing")
assertTrue(out.contains("не найден"))
assertTrue(sender.sent.isEmpty())
}
@Test
fun makeMirrorPostsCreateMirrorRequest() = runTest {
var capturedPath: String? = null
var capturedMethod: HttpMethod? = null
var capturedBody: String? = null
val mirrorClient = mirror { request ->
capturedPath = request.url.encodedPath
capturedMethod = request.method
capturedBody = (request.body as TextContent).text
respond(
content = """{"id":"m-1","status":"processing"}""",
status = HttpStatusCode.Created,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val out = actions(simpleJellyfin(), mirrorClient, SenderStub()).makeMirror("m1")
assertEquals("/api/mirror", capturedPath)
assertEquals(HttpMethod.Post, capturedMethod)
val sent = Json.decodeFromString<CreateMirrorRequest>(capturedBody!!)
assertEquals("m1", sent.itemId)
assertTrue(out.contains("m-1"))
assertTrue(out.contains("processing"))
}
@Test
fun mirrorStatusReturnsText() = runTest {
val done = actions(simpleJellyfin(), doneMirror(), SenderStub()).mirrorStatus("m1")
assertTrue(done.contains("done"))
assertTrue(done.contains("✓"))
val missing = mirror { request -> respond("", HttpStatusCode.NotFound) }
val none = actions(simpleJellyfin(), missing, SenderStub()).mirrorStatus("m1")
assertTrue(none.contains("Зеркало: нет"))
}
@Test
fun selectSessionBroadcastsChatSessionMsg() = runTest {
val sender = SenderStub()
val out = actions(simpleJellyfin(), doneMirror(), sender).selectSession("work")
assertTrue(out.contains("Работа"))
assertEquals("work", sender.phoneState.activeSessionId)
val msg = sender.sent.single()
assertIs<ChatSessionMsg>(msg)
assertEquals("work", msg.sessionId)
assertEquals("Работа", msg.title)
}
@Test
fun selectSessionUnknownReturnsError() = runTest {
val sender = SenderStub()
val out = actions(simpleJellyfin(), doneMirror(), sender).selectSession("nope")
assertTrue(out.contains("не найдена"))
assertTrue(sender.sent.isEmpty())
assertNull(sender.phoneState.activeSessionId)
}
@Test
fun sessionsListsAllSessions() = runTest {
val sender = SenderStub()
sender.phoneState.activeSessionId = "work"
val out = actions(simpleJellyfin(), doneMirror(), sender).sessions()
assertTrue(out.contains("glasses-movie — Очки/кино"))
assertTrue(out.contains("work — Работа"))
assertTrue(out.contains("general — Общее"))
assertTrue(out.contains("◄ активна"))
}
@Test
fun downloadAndDeleteDownloadedEmulate() {
val sender = SenderStub()
val actions = actions(simpleJellyfin(), doneMirror(), sender)
val dl = actions.download("m1", "очки")
assertTrue(dl.contains("скачано m1"))
assertEquals("очки", sender.phoneState.downloaded["m1"])
val rm = actions.deleteDownloaded("m1")
assertTrue(rm.contains("удалено скачанное"))
assertFalse(sender.phoneState.downloaded.containsKey("m1"))
val rm2 = actions.deleteDownloaded("m1")
assertTrue(rm2.contains("не было скачано"))
}
@Test
fun statusShowsCurrentState() = runTest {
val sender = SenderStub()
val actions = actions(simpleJellyfin(), doneMirror(), sender)
val empty = actions.status()
assertTrue(empty.contains("Режим: MOVIE"))
assertTrue(empty.contains("ничего"))
assertTrue(empty.contains("пауза"))
actions.watch("m1")
val after = actions.status()
assertTrue(after.contains("Играет: Интерстеллар (m1)"))
assertTrue(after.contains("играет"))
assertTrue(after.contains("Озвучка: #0"))
}
@Test
fun applyModeSendsSetModeAndUpdatesState() = runTest {
val sender = SenderStub()
val out = actions(simpleJellyfin(), doneMirror(), sender).applyMode(pw.binom.viewmate.core.GlassesMode.CHAT)
assertTrue(out.contains("CHAT"))
assertEquals(pw.binom.viewmate.core.GlassesMode.CHAT, sender.phoneState.mode)
val msg = sender.sent.single()
assertIs<pw.binom.viewmate.core.protocol.SetMode>(msg)
assertEquals(pw.binom.viewmate.core.GlassesMode.CHAT, msg.mode)
}
}
@@ -0,0 +1,238 @@
package pw.binom.viewmate.core.net
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.protocol.Gesture
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 java.util.concurrent.ConcurrentLinkedQueue
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class GlassesWsClientTest {
companion object {
private const val PATH = "/ws/glasses"
}
/** Локальный WS-сервер: Hello → Welcome, всё входящее собирает в список. */
private class TestServer(private val port: Int = 0) {
val received = ConcurrentLinkedQueue<GlassesToHost>()
private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>()
private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") {
install(WebSockets) {
pingPeriodMillis = 5_000
timeoutMillis = 5_000
maxFrameSize = Long.MAX_VALUE
masking = false
}
routing {
webSocket(PATH) {
sessions.add(this)
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), frame.readText())
received.add(msg)
if (msg is Hello) {
send(protocolJson.encodeToString(
HostToGlasses.serializer(),
Welcome(mode = GlassesMode.MOVIE),
))
}
}
}
} finally {
sessions.remove(this)
}
}
}
}
init {
server.start(wait = false)
}
suspend fun boundPort(): Int = server.engine.resolvedConnectors().first().port
suspend fun url(): String = "ws://127.0.0.1:${boundPort()}$PATH"
val sessionCount: Int
get() = sessions.size
suspend fun broadcast(msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
for (s in sessions) {
try {
s.send(text)
} catch (_: Exception) {
// клиент мог отвалиться
}
}
}
fun stop() {
server.stop(500, 1_000)
}
}
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (!condition()) {
if (System.currentTimeMillis() > deadline) {
error("условие не выполнено за ${timeoutMs}мс")
}
delay(50)
}
}
@Test
fun helloGetsWelcomeRoundTrip() {
val server = TestServer()
try {
runBlocking {
val client = GlassesWsClient(server.url())
val messages = mutableListOf<HostToGlasses>()
val connected = CompletableDeferred<Unit>()
val job = launch {
client.connect(
onHostMessage = { messages.add(it) },
onConnected = { connected.complete(Unit) },
)
}
withTimeout(10_000) { connected.await() }
client.send(Hello(appVersion = "0.1.0"))
awaitTrue { messages.any { it is Welcome } }
val welcome = messages.filterIsInstance<Welcome>().first()
assertEquals(GlassesMode.MOVIE, welcome.mode)
client.close()
job.cancelAndJoin()
}
} finally {
server.stop()
}
}
@Test
fun sendGestureReachesServer() {
val server = TestServer()
try {
runBlocking {
val client = GlassesWsClient(server.url())
val connected = CompletableDeferred<Unit>()
val job = launch {
client.connect(
onHostMessage = {},
onConnected = { connected.complete(Unit) },
)
}
withTimeout(10_000) { connected.await() }
client.send(Gesture(gesture = "DoubleClick"))
awaitTrue { server.received.any { it is Gesture } }
val gesture = server.received.filterIsInstance<Gesture>().first()
assertEquals("DoubleClick", gesture.gesture)
client.close()
job.cancelAndJoin()
}
} finally {
server.stop()
}
}
@Test
fun receivesHostMessage() {
val server = TestServer()
try {
runBlocking {
val client = GlassesWsClient(server.url())
val messages = mutableListOf<HostToGlasses>()
val connected = CompletableDeferred<Unit>()
val job = launch {
client.connect(
onHostMessage = { messages.add(it) },
onConnected = { connected.complete(Unit) },
)
}
withTimeout(10_000) { connected.await() }
awaitTrue { server.sessionCount == 1 }
server.broadcast(SetMode(GlassesMode.CHAT))
awaitTrue { messages.any { it is SetMode } }
val setMode = messages.filterIsInstance<SetMode>().first()
assertEquals(GlassesMode.CHAT, setMode.mode)
client.close()
job.cancelAndJoin()
}
} finally {
server.stop()
}
}
@Test
fun reconnectsAfterServerDrop() {
val server = TestServer()
try {
runBlocking {
val port = server.boundPort()
var connects = 0
val client = GlassesWsClient(server.url())
val job = launch {
client.connect(
onHostMessage = {},
onConnected = { connects++ },
)
}
awaitTrue { connects >= 1 }
server.stop()
// сервер упал → клиент должен продолжать цикл (не выходить) и реконнектиться
delay(4_000)
kotlin.test.assertTrue(job.isActive, "клиент должен пережить обрыв сервера")
// поднимаем сервер на том же порту → клиент подключается заново
val server2 = TestServer(port)
try {
awaitTrue { connects >= 2 }
client.close()
job.cancelAndJoin()
} finally {
server2.stop()
}
}
} finally {
server.stop()
}
}
}