feat(chat): 加 REST 发送路径 —— 长文与图片(含 clientMsgId 幂等与归一)
客户端此前**只有 WS** 发送,而 WS 帧硬上限 4096 字节、**超限是直接断连**(不是报个错),
所以长文发不出去、图完全发不了。v0.56 给 REST 流式补齐了明确响应面与 clientMsgId,
本次照它接入。
- data/api:ChatApi + ChatDto(窄请求体,不回环整条会话)+ ChatRepository
—— **刻意不走 `apiCall`**:它把 4xx/5xx 一律折进 `Result.failure` 并丢掉状态码,
那样 `202`(已排队)与 `409`(队满)的差别会在那一刻消失;改为映射成 ChatAck 密封结果
(Accepted / Queued / QueueFull / Duplicate / Unavailable / Rejected)
- domain/chat/ChatSendRouting:纯函数路由 —— **UTF-8 字节数 ≤ 3584 且无图 → WS,否则 REST**。
阈值按**字节**不按字符(中文 1 字 3 字节);预留 512 字节覆盖帧壳。短文本仍走 WS 是刻意的:
那条路**绕开有损总线**,而超 WS 上限的代价是**断连**而非报错,两边风险不对称
- domain/chat/ChatImageBudget + data/image/ChatImagePreparer:解码 → 等比缩放 → 重编码为
JPEG/PNG data URI;超预算自动降尺寸重来;**失败返回 null,绝不退回原图 base64**
- clientMsgId:每次发送 `"local-" + UUID`(**随机不自增**——服务端判重窗口是**整个会话历史
且无过期清理**,进程重启后复用旧值会让那条消息永远发不出去);REST 与 WS 两路都带
- 归一:带 clientMsgId 的消息**只按 id 匹配**(文本不同也命中;匹配不到就什么都不吃,
**绝不回落到文本**——那会吃掉一条无关气泡);不带键的老行才回落「第一条同文本」
- ack 分支:200 已受理(等 message_persisted)/ 202 已排队(出「已排队 N 条」)/
**409 既未受理也未落库 → 回滚气泡并把文本与图片还回输入框**(不能当「已排队」,
那会让气泡永远挂着「发送中…」)/ duplicate 不是错误(收气泡;本地没有该 messageId 就补拉一页)/
500、503 回滚 / 400 image_too_large 换短文案
- 接下行帧 `chat_duplicate`;图片一律走 REST(`WsOutbound.Chat.image` 仍无生产者)
测试:269 全绿(218 → +51)。含真实抓包的四段响应(200 / 202 / duplicate / 400 image_too_large,
且服务端文案里的 6990572 与本端 SERVER_MAX_CHARS 做成对断言)、路由阈值边界
(1194 汉字 = 3582 字节走 WS、1195 = 3585 走 REST,并直接断言阈值处真实
`WsOutbound.Chat(...)` 序列化后 ≤ 4096 字节)、归一规则、六种 ack 映射。
真机(真后端 127.0.0.1:10942):**图片全链路通过** —— 选图 → 重编码(源 7.8 KB PNG →
1 KB JPEG,magic ffd8ffe0)→ `POST /api/v1/chat`(1558 字节体)→
`200 {"data":{"stream":true}}` → 气泡由「发送中…」归一为「来自 安卓」→ 昔涟回复**准确描述了那张图**。
另 WS 短文本路径未回归(无 POST,且 clientMsgId 被 v0.56 原样回填)。
未验证:`409 chat_busy` 与 `500` 非真实抓包(要填满主人后端的待处理队列、等于真跑 5 个回合,
判断不值得;形状与文案逐字取自后端源码,测试注释里已写明);UI 上的 202/409 未端到端跑过;
降尺寸「减半重来」那步未在设备上跑过(测试图 64×64 只走到重编码)。
This commit is contained in:
1 parent
4c14028c70
commit
e642b0c18c
19 files changed
+1615
-35
No files matched your search
@@ -0,0 +1,180 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
import top.yeij.cyrene.data.api.ApiErrorMapper
|
||||
|
||||
/**
|
||||
* 契约钉死测试:用**线上后端实际抓下来的响应体**(`POST /api/v1/chat`,v0.56,2026-10-04),
|
||||
* 逐字节照抄(只把 id 换成占位值)。解码器用 [ApiClientFactory.json]——与生产同一份 `Json`
|
||||
* 配置,所以这里过不了的形状,线上也一定过不了。
|
||||
*
|
||||
* 覆盖 v0.56 才出现、手写 JSON 容易漏掉的形状:`stream:true` **不再恒 200**
|
||||
* (`202` 带 `queued` + `queueDepth`、`409 chat_busy`、`500 chat_unavailable`)、
|
||||
* 幂等重复的 `{duplicate:true,messageId,sessionId}`、以及 `400 image_too_large` 的长中文文案。
|
||||
*/
|
||||
class ChatDtoRealPayloadTest {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
// —— 以下 4 段是真实响应体(2026-10-04 线上抓取,逐字节照抄)——
|
||||
|
||||
/** `200`:空闲会话受理成功(`stream:true`)。 */
|
||||
private val realAccepted = """{"data":{"stream":true}}"""
|
||||
|
||||
/** `202`:会话正忙,已续接排队(注意键序,解出来是同一件事)。 */
|
||||
private val realQueued = """{"data":{"queueDepth":1,"queued":true,"stream":true}}"""
|
||||
|
||||
/** `200`:同一个 `clientMsgId` 第二次到达(幂等命中,未落库、未起回合)。 */
|
||||
private val realDuplicate = """{"data":{"duplicate":true,""" +
|
||||
""""messageId":"09ddfc26b0ab23afd75436dfe152098d",""" +
|
||||
""""sessionId":"web:cd42a6652cfc58ef0d5869bc79e81460"}}"""
|
||||
|
||||
/** `400`:image 超过解码后 5 MiB 的硬上限(文案里的字符数是服务端算出来的真值)。 */
|
||||
private val realImageTooLarge = """{"error":{"code":"image_too_large","message":""" +
|
||||
""""图片过大:image 上限约 5.0 MiB(base64 字符串上限 6990572 字符,本次 6990595 字符);""" +
|
||||
"""请先 POST /api/v1/upload 上传,再用返回的 url 作为 image 发送"}}"""
|
||||
|
||||
/**
|
||||
* `409`:待处理队列已满。**这一段不是抓来的**——它要求先把某个会话的待处理队列(上限 5,
|
||||
* `session_orchestrator.go`)填满,代价是让主人的后端真的跑 5 个回合;形状与文案逐字取自
|
||||
* 后端实现 `internal/services/gateway/chat_stream_dispatch.go:123`
|
||||
* (`apperr.ErrConflict` + `"chat_busy"` + `"会话正忙,待处理消息已满"`),
|
||||
* 并由后端自己的 e2e `chat_idempotency_test.go` 断言该 code。其余四段都是真实抓包。
|
||||
*/
|
||||
private val realBusy = """{"error":{"code":"chat_busy","message":"会话正忙,待处理消息已满"}}"""
|
||||
|
||||
private fun ack(body: String) =
|
||||
json.decodeFromString(ApiEnvelope.serializer(ChatAckDto.serializer()), body).data!!
|
||||
|
||||
@Test
|
||||
fun `accepted ack is stream true and says nothing else`() {
|
||||
val data = ack(realAccepted)
|
||||
|
||||
assertEquals(true, data.stream)
|
||||
// 其余键整键缺席 → null:绝不能把「没有排队」和「排队 0 条」混为一谈。
|
||||
assertNull(data.queued)
|
||||
assertNull(data.queueDepth)
|
||||
assertNull(data.duplicate)
|
||||
assertNull(data.messageId)
|
||||
assertNull(data.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `queued ack keeps stream flag queued flag and depth`() {
|
||||
val data = ack(realQueued)
|
||||
|
||||
assertEquals(true, data.stream)
|
||||
assertEquals(true, data.queued)
|
||||
// 202 是「已续接排队」:深度必须真的读出来,UI 的「已排队 N 条」全靠它。
|
||||
assertEquals(1, data.queueDepth)
|
||||
assertNull(data.duplicate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate ack carries the existing message id for bubble alignment`() {
|
||||
val data = ack(realDuplicate)
|
||||
|
||||
assertEquals(true, data.duplicate)
|
||||
assertEquals("09ddfc26b0ab23afd75436dfe152098d", data.messageId)
|
||||
assertEquals("web:cd42a6652cfc58ef0d5869bc79e81460", data.sessionId)
|
||||
// 重复到达**没有**回 stream:它没有起回合,客户端不能把它当「回合已起」。
|
||||
assertNull(data.stream)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chat_busy 409 is an error envelope not an ack`() {
|
||||
val envelope = json.decodeFromString(ApiEnvelope.serializer(ChatAckDto.serializer()), realBusy)
|
||||
|
||||
// 409 走 `{error}` 信封:data 缺席、error 有码——这正是「不能折进同一个 Result 分支」的原因。
|
||||
assertNull(envelope.data)
|
||||
assertEquals("chat_busy", envelope.error!!.code)
|
||||
assertEquals("会话正忙,待处理消息已满", envelope.error!!.message)
|
||||
|
||||
val mapped = ApiErrorMapper.fromHttp(409, realBusy) as CyreneError.Server
|
||||
assertEquals("chat_busy", mapped.code)
|
||||
assertEquals(409, mapped.httpStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `image_too_large 400 keeps the code the client branches on`() {
|
||||
val mapped = ApiErrorMapper.fromHttp(400, realImageTooLarge) as CyreneError.Server
|
||||
|
||||
assertEquals("image_too_large", mapped.code)
|
||||
assertEquals(400, mapped.httpStatus)
|
||||
// 文案由服务端产出(含真实上限),客户端原样展示,不自己编数字。
|
||||
assertTrue(mapped.message.contains("5.0 MiB"))
|
||||
assertTrue(mapped.message.contains("/api/v1/upload"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the server-side base64 cap in the real message equals the client constant`() {
|
||||
// 真实文案里写着 base64 字符串上限 = 6990572 字符(= ceil(5 MiB / 3) * 4 + 64)。
|
||||
// 客户端 `ChatImageBudget.SERVER_MAX_CHARS` 必须与它**逐字相等**,否则会出现
|
||||
// 「本地觉得没超、服务端 400」或反之的漂移。
|
||||
val declared = realImageTooLarge.substringAfter("base64 字符串上限 ").substringBefore(" 字符")
|
||||
|
||||
assertEquals(6990572L, declared.toLong())
|
||||
assertEquals(
|
||||
declared.toLong(),
|
||||
top.yeij.cyrene.domain.chat.ChatImageBudget.SERVER_MAX_CHARS.toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the request body keeps the documented key names and omits empty optional fields`() {
|
||||
val encoded = json.encodeToString(
|
||||
ChatRequest.serializer(),
|
||||
ChatRequest(
|
||||
sessionId = "web",
|
||||
text = "长文本",
|
||||
deviceId = "voiceassist:cyrene:v1",
|
||||
stream = true,
|
||||
clientMsgId = "local-1",
|
||||
),
|
||||
)
|
||||
val raw = json.parseToJsonElement(encoded).jsonObject
|
||||
|
||||
// 键名逐字对齐契约(`clientMsgId` 是 camelCase,不是 `client_msg_id`)。
|
||||
assertEquals(setOf("sessionId", "text", "deviceId", "stream", "clientMsgId"), raw.keys)
|
||||
assertEquals("web", raw["sessionId"]!!.jsonPrimitive.content)
|
||||
assertEquals("长文本", raw["text"]!!.jsonPrimitive.content)
|
||||
assertEquals("local-1", raw["clientMsgId"]!!.jsonPrimitive.content)
|
||||
assertTrue(raw["stream"]!!.jsonPrimitive.boolean)
|
||||
// 没给的字段整键省略(`image` / `mode`):服务端 `omitempty` 同语义。
|
||||
assertFalse(raw.containsKey("image"))
|
||||
assertFalse(raw.containsKey("mode"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an image-only request omits text and still carries image`() {
|
||||
val encoded = json.encodeToString(
|
||||
ChatRequest.serializer(),
|
||||
ChatRequest(sessionId = "web", image = "data:image/jpeg;base64,AAAA", stream = true),
|
||||
)
|
||||
val raw = json.parseToJsonElement(encoded).jsonObject
|
||||
|
||||
// `encodeDefaults = false`:空文本整键省略。服务端 Go 侧零值同为空串,语义等价——
|
||||
// 这条测试把该事实钉住,免得日后有人以为「发图必须带空 text 键」。
|
||||
assertFalse(raw.containsKey("text"))
|
||||
assertEquals("data:image/jpeg;base64,AAAA", raw["image"]!!.jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `queueDepth decodes as an integer not a string`() {
|
||||
val raw = json.parseToJsonElement(realQueued).jsonObject["data"]!!.jsonObject
|
||||
|
||||
assertFalse("queueDepth 线上是数字", raw["queueDepth"]!!.jsonPrimitive.isString)
|
||||
assertEquals(1, raw["queueDepth"]!!.jsonPrimitive.int)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ChatApi
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.ChatRequest
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* `POST /api/v1/chat` 的**状态码映射**,端到端跑真 OkHttp + 真 Retrofit + 真 HTTP 服务端
|
||||
* (JDK 自带的 `com.sun.net.httpserver`,不引任何新依赖)。
|
||||
*
|
||||
* 这里要证明的核心是**「非 2xx 不等于失败」**:v0.56 起 `202` 排队与 `409` 排队满是编排器的
|
||||
* 正常回执,`202` 还落在 Retrofit 的 `isSuccessful` 里、`409` 不在——用 `apiCall` 一把折进
|
||||
* `Result.failure` 就会把两者压成同一个东西。所以每条断言都对着**状态码本身**,
|
||||
* 而且用真实响应体(形状与线上一致)。
|
||||
*/
|
||||
class ChatRepositoryTest {
|
||||
|
||||
private val backend = FakeChatBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
private fun repository() = ChatRepository(
|
||||
ApiClientFactory.create(baseUrl = backend.baseUrl, service = ChatApi::class.java),
|
||||
)
|
||||
|
||||
private fun request(text: String = "长文本", clientMsgId: String = "local-1") = ChatRequest(
|
||||
sessionId = "web",
|
||||
text = text,
|
||||
deviceId = "voiceassist:cyrene:v1",
|
||||
stream = true,
|
||||
clientMsgId = clientMsgId,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `200 stream true maps to accepted`() = runBlocking {
|
||||
backend.reply(200, """{"data":{"stream":true}}""")
|
||||
|
||||
val ack = repository().send(request()).getOrThrow()
|
||||
|
||||
assertEquals(ChatAck.Accepted, ack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `202 queued keeps the queue depth instead of being folded into an error`() = runBlocking {
|
||||
backend.reply(202, """{"data":{"stream":true,"queued":true,"queueDepth":3}}""")
|
||||
|
||||
val ack = repository().send(request()).getOrThrow()
|
||||
|
||||
// 202 是「已受理、已续接排队」:既不是失败,也不是「已起回合」。
|
||||
assertEquals(ChatAck.Queued(queueDepth = 3), ack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `200 duplicate carries the existing message id`() = runBlocking {
|
||||
backend.reply(
|
||||
200,
|
||||
"""{"data":{"duplicate":true,"messageId":"m-42","sessionId":"web"}}""",
|
||||
)
|
||||
|
||||
val ack = repository().send(request()).getOrThrow()
|
||||
|
||||
assertEquals(ChatAck.Duplicate(messageId = "m-42", sessionId = "web"), ack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `409 chat_busy is queue full and not a network failure`() = runBlocking {
|
||||
backend.reply(409, """{"error":{"code":"chat_busy","message":"会话正忙,待处理消息已满"}}""")
|
||||
|
||||
val result = repository().send(request())
|
||||
|
||||
// 关键:**成功**地拿到了「队列满」这个业务判定,调用方能把它和 Accepted/Queued 分开。
|
||||
assertEquals(ChatAck.QueueFull, result.getOrThrow())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `500 chat_unavailable is surfaced with its code`() = runBlocking {
|
||||
backend.reply(
|
||||
500,
|
||||
"""{"error":{"code":"chat_unavailable","message":"chat service is not configured"}}""",
|
||||
)
|
||||
|
||||
val ack = repository().send(request()).getOrThrow()
|
||||
|
||||
assertEquals(ChatAck.Unavailable("chat_unavailable", "chat service is not configured"), ack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `503 chat_dispatch_failed is surfaced with its code`() = runBlocking {
|
||||
backend.reply(
|
||||
503,
|
||||
"""{"error":{"code":"chat_dispatch_failed","message":"流式通道不可用,本轮未提交;请重试"}}""",
|
||||
)
|
||||
|
||||
val ack = repository().send(request()).getOrThrow()
|
||||
|
||||
assertEquals(ChatAck.Unavailable("chat_dispatch_failed", "流式通道不可用,本轮未提交;请重试"), ack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `400 image_too_large keeps the code the caller branches on`() = runBlocking {
|
||||
backend.reply(
|
||||
400,
|
||||
"""{"error":{"code":"image_too_large","message":"图片过大:image 上限约 5.0 MiB"}}""",
|
||||
)
|
||||
|
||||
val ack = repository().send(request()).getOrThrow()
|
||||
|
||||
assertEquals(ChatAck.Rejected("image_too_large", "图片过大:image 上限约 5.0 MiB"), ack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `400 bad_request is rejected rather than retried`() = runBlocking {
|
||||
backend.reply(400, """{"error":{"code":"bad_request","message":"text or image is required"}}""")
|
||||
|
||||
val ack = repository().send(request()).getOrThrow()
|
||||
|
||||
assertEquals(ChatAck.Rejected("bad_request", "text or image is required"), ack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `401 stays a failure because the session itself is no longer valid`() = runBlocking {
|
||||
backend.reply(401, """{"error":{"code":"unauthorized","message":"auth: unauthorized"}}""")
|
||||
|
||||
val error = repository().send(request()).exceptionOrNull() as CyreneException
|
||||
|
||||
assertTrue(error.error is CyreneError.Unauthorized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 2xx without data is a parse failure not an acceptance`() = runBlocking {
|
||||
backend.reply(200, "{}")
|
||||
|
||||
val error = repository().send(request()).exceptionOrNull() as CyreneException
|
||||
|
||||
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreachable server is a local failure`() = runBlocking {
|
||||
backend.close()
|
||||
|
||||
val error = repository().send(request()).exceptionOrNull() as CyreneException
|
||||
|
||||
assertTrue("应当是本地连接类错误,实际 ${error.error}", error.error is CyreneError.Local)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the request actually carries stream true clientMsgId and the image`() = runBlocking {
|
||||
backend.reply(200, """{"data":{"stream":true}}""")
|
||||
|
||||
repository().send(
|
||||
ChatRequest(
|
||||
sessionId = "web",
|
||||
text = "",
|
||||
image = "data:image/jpeg;base64,AAAA",
|
||||
deviceId = "voiceassist:cyrene:v1",
|
||||
stream = true,
|
||||
clientMsgId = "local-7",
|
||||
),
|
||||
).getOrThrow()
|
||||
|
||||
val body = backend.bodies.single()
|
||||
// 线上必须真的带 `stream:true`(否则会走同步路径、被 60s 读超时打死)。
|
||||
assertTrue(body, body.contains("\"stream\":true"))
|
||||
assertTrue(body, body.contains("\"clientMsgId\":\"local-7\""))
|
||||
assertTrue(body, body.contains("\"image\":\"data:image/jpeg;base64,AAAA\""))
|
||||
assertTrue(body, body.contains("\"sessionId\":\"web\""))
|
||||
// 逐字对齐契约:不是 `client_msg_id`、不是 `clientMessageId`。
|
||||
assertEquals(1, Regex("\"clientMsgId\"").findAll(body).count())
|
||||
}
|
||||
|
||||
/** 一个只会照着 `reply(...)` 回话的假后端:记录收到的请求体,绝不校验鉴权。 */
|
||||
private inner class FakeChatBackend : AutoCloseable {
|
||||
|
||||
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/api/v1/chat", ::handle)
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
val bodies = CopyOnWriteArrayList<String>()
|
||||
|
||||
@Volatile
|
||||
private var status: Int = 200
|
||||
|
||||
@Volatile
|
||||
private var body: String = """{"data":{"stream":true}}"""
|
||||
|
||||
fun reply(status: Int, body: String) {
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
bodies += exchange.requestBody.readBytes().decodeToString()
|
||||
val bytes = body.toByteArray()
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(status, bytes.size.toLong())
|
||||
exchange.responseBody.use { it.write(bytes) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 下行帧 `chat_duplicate`(契约 v0.56 §3.13,纯新增)。
|
||||
*
|
||||
* 帧壳带 `sessionId`、**不带** `turnId` / `seq`(它与 `session_busy` 同一出口,不认识「轮次」)。
|
||||
* 这是幂等重发的明确回执:不落库、不起回合、不发内容帧——客户端只能靠 [WsEvent.ChatDuplicate.messageId]
|
||||
* 把本地乐观气泡对齐到服务端真相。
|
||||
*/
|
||||
class WsEventChatDuplicateTest {
|
||||
|
||||
/** 真实帧形状(服务端 `session_events.go` 的 `EventChatDuplicate` 载荷)。 */
|
||||
private val realFrame = """
|
||||
{"type":"chat_duplicate","sessionId":"web","data":{"sessionId":"web","clientMsgId":"local-1","messageId":"m-42"}}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `real chat_duplicate frame decodes`() {
|
||||
val event = WsEvent.parse(realFrame) as WsEvent.ChatDuplicate
|
||||
|
||||
assertEquals("chat_duplicate", event.type)
|
||||
assertEquals("web", event.sessionId)
|
||||
assertEquals("local-1", event.clientMsgId)
|
||||
assertEquals("m-42", event.messageId)
|
||||
// 状态机会话帧不带轮次标识。
|
||||
assertNull(event.turnId)
|
||||
assertNull(event.seq)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shell sessionId wins when the payload omits it`() {
|
||||
val frame = """{"type":"chat_duplicate","sessionId":"web:abc","data":{"clientMsgId":"local-2"}}"""
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.ChatDuplicate
|
||||
|
||||
assertEquals("web:abc", event.sessionId)
|
||||
assertEquals("local-2", event.clientMsgId)
|
||||
assertNull("载荷没给 messageId 就是 null,不拍成空串", event.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the payload sessionId fills in when the shell omits it`() {
|
||||
val frame = """{"type":"chat_duplicate","data":{"sessionId":"web:xyz","clientMsgId":"local-3"}}"""
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.ChatDuplicate
|
||||
|
||||
assertEquals("web:xyz", event.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chat_duplicate without a payload still parses instead of being dropped`() {
|
||||
// 缺 clientMsgId 时客户端无从对齐,但**绝不能**因此整帧失败——
|
||||
// 未知/残缺形状按「拿不到就不做事」处理,而不是让解析器吞掉整类事件。
|
||||
val event = WsEvent.parse("""{"type":"chat_duplicate","sessionId":"web"}""") as WsEvent.ChatDuplicate
|
||||
|
||||
assertEquals("", event.clientMsgId)
|
||||
assertEquals("web", event.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chat_duplicate is scoped to its own session`() {
|
||||
val event = WsEvent.parse(realFrame)!!
|
||||
|
||||
assertTrue(event.isForSession("web"))
|
||||
// 服务端广播不按会话订阅:别的会话的幂等回执必须被这道闸挡在外面,
|
||||
// 否则会按值去吃掉本会话无关的气泡。
|
||||
assertTrue(!event.isForSession("web:other"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.ws.WsOutbound
|
||||
|
||||
/**
|
||||
* 上行路由规则与图片预算(纯函数)。
|
||||
*
|
||||
* 判据是**UTF-8 字节数**:WS 帧上限是服务端的 `wsReadLimit = 4096` 字节,而超限的后果不是
|
||||
* 「服务端回个错误」,是**传输层直接断连**——所以这条规则宁早不晚。
|
||||
*/
|
||||
class ChatSendPolicyTest {
|
||||
|
||||
// —— 路由 ——
|
||||
|
||||
@Test
|
||||
fun `short text goes over the websocket`() {
|
||||
assertEquals(SendChannel.Ws, ChatSendRouting.channelFor("你好", hasImage = false))
|
||||
assertEquals(SendChannel.Ws, ChatSendRouting.channelFor("hello", hasImage = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `long text goes over REST`() {
|
||||
val text = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES + 1)
|
||||
|
||||
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor(text, hasImage = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `any image goes over REST even when the text is one character`() {
|
||||
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor("嗨", hasImage = true))
|
||||
// 纯图片(空文本)也必须走 REST:base64 不可能塞进 4096 字节的帧。
|
||||
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor("", hasImage = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the threshold is inclusive on the websocket side`() {
|
||||
val atLimit = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES)
|
||||
val overLimit = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES + 1)
|
||||
|
||||
assertEquals(ChatSendRouting.WS_TEXT_LIMIT_BYTES, ChatSendRouting.utf8Length(atLimit))
|
||||
assertEquals(SendChannel.Ws, ChatSendRouting.channelFor(atLimit, hasImage = false))
|
||||
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor(overLimit, hasImage = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the threshold counts bytes not characters so CJK falls back earlier`() {
|
||||
// 中文一个字 3 字节:1194 字 = 3582 字节(仍在限内),1195 字 = 3585 字节(超限)。
|
||||
// 若按「字符数 ≤ 3584」判,333 个汉字时就该退到 REST 却仍会走 WS —— 这正是要避免的。
|
||||
val fits = "昔".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES / 3)
|
||||
val overflows = "昔".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES / 3 + 1)
|
||||
|
||||
assertEquals(3582, ChatSendRouting.utf8Length(fits))
|
||||
assertEquals(SendChannel.Ws, ChatSendRouting.channelFor(fits, hasImage = false))
|
||||
assertEquals(3585, ChatSendRouting.utf8Length(overflows))
|
||||
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor(overflows, hasImage = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an emoji outside the BMP is four bytes like the wire says`() {
|
||||
// UTF-16 里一个 emoji 是 2 个 char,但 WS 上按 UTF-8 算是 4 字节 —— 用 `length` 会低估一半。
|
||||
assertEquals(2, "🎐".length)
|
||||
assertEquals(4, ChatSendRouting.utf8Length("🎐"))
|
||||
}
|
||||
|
||||
// —— 阈值本身与「真实帧不会超 4096」 ——
|
||||
|
||||
@Test
|
||||
fun `the websocket budget leaves room for the frame envelope`() {
|
||||
assertEquals(4096, ChatSendRouting.WS_FRAME_LIMIT_BYTES)
|
||||
assertTrue(
|
||||
"文本上限必须真的给 JSON 外壳留出空间",
|
||||
ChatSendRouting.WS_TEXT_LIMIT_BYTES < ChatSendRouting.WS_FRAME_LIMIT_BYTES,
|
||||
)
|
||||
assertEquals(
|
||||
ChatSendRouting.WS_FRAME_LIMIT_BYTES - ChatSendRouting.WS_ENVELOPE_RESERVE_BYTES,
|
||||
ChatSendRouting.WS_TEXT_LIMIT_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chat frame at the threshold still fits the 4096 byte read limit`() {
|
||||
val text = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES)
|
||||
|
||||
val frame = WsOutbound.Chat(
|
||||
text = text,
|
||||
sessionId = "web",
|
||||
deviceId = "voiceassist:cyrene:v1",
|
||||
clientMsgId = "local-8f14e45f-ceea-467f-a1b2-9d5d0e3c7a41",
|
||||
).toJson()
|
||||
|
||||
// 真实的 UTF-8 字节数必须 ≤ 服务端读上限,否则这条帧会让服务端**直接断连**。
|
||||
assertTrue(
|
||||
"帧 ${ChatSendRouting.utf8Length(frame)} 字节超过了 4096",
|
||||
ChatSendRouting.utf8Length(frame) <= ChatSendRouting.WS_FRAME_LIMIT_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a CJK chat frame at the threshold still fits`() {
|
||||
// 逐字符碰到上限为止:取最大的、字节数仍 ≤ 上限的中文串。
|
||||
val text = "昔".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES / 3)
|
||||
|
||||
val frame = WsOutbound.Chat(
|
||||
text = text,
|
||||
sessionId = "web",
|
||||
deviceId = "voiceassist:cyrene:v1",
|
||||
clientMsgId = "local-8f14e45f-ceea-467f-a1b2-9d5d0e3c7a41",
|
||||
).toJson()
|
||||
|
||||
assertTrue(ChatSendRouting.utf8Length(frame) <= ChatSendRouting.WS_FRAME_LIMIT_BYTES)
|
||||
}
|
||||
|
||||
// —— 图片预算 ——
|
||||
|
||||
@Test
|
||||
fun `base64 length follows the 4 to 3 expansion`() {
|
||||
assertEquals(0, ChatImageBudget.base64Chars(0))
|
||||
assertEquals(4, ChatImageBudget.base64Chars(1))
|
||||
assertEquals(4, ChatImageBudget.base64Chars(3))
|
||||
assertEquals(8, ChatImageBudget.base64Chars(4))
|
||||
assertEquals(8, ChatImageBudget.base64Chars(6))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the server character cap matches 5 MiB plus the data uri prefix allowance`() {
|
||||
val expected = ((ChatImageBudget.SERVER_MAX_DECODED_BYTES + 2) / 3) * 4 + 64
|
||||
|
||||
assertEquals(5 * 1024 * 1024, ChatImageBudget.SERVER_MAX_DECODED_BYTES)
|
||||
assertEquals(expected, ChatImageBudget.SERVER_MAX_CHARS)
|
||||
// 服务端 `checkChatImageSize` 的判据是 `len(image) <= limit`:等于上限必须放行。
|
||||
assertTrue(ChatImageBudget.withinServerLimit(ChatImageBudget.SERVER_MAX_CHARS))
|
||||
assertFalse(ChatImageBudget.withinServerLimit(ChatImageBudget.SERVER_MAX_CHARS + 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the client target budget is far below the server hard cap`() {
|
||||
assertTrue(ChatImageBudget.withinTarget(ChatImageBudget.MAX_DECODED_BYTES))
|
||||
assertFalse(ChatImageBudget.withinTarget(ChatImageBudget.MAX_DECODED_BYTES + 1))
|
||||
// 本端目标必须明显更紧:服务端只管「收不收」,管不了「历史有多重」。
|
||||
assertTrue(ChatImageBudget.MAX_DECODED_BYTES * 4 < ChatImageBudget.SERVER_MAX_DECODED_BYTES)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scaling never upscales and keeps the aspect ratio`() {
|
||||
assertEquals(ScaledSize(800, 600), ChatImageBudget.scaledSize(800, 600))
|
||||
assertEquals(ScaledSize(1280, 1280), ChatImageBudget.scaledSize(1280, 1280))
|
||||
assertEquals(ScaledSize(1280, 960), ChatImageBudget.scaledSize(4000, 3000))
|
||||
assertEquals(ScaledSize(960, 1280), ChatImageBudget.scaledSize(3000, 4000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scaling a very thin image keeps both sides at least one pixel`() {
|
||||
val scaled = ChatImageBudget.scaledSize(1, 5000)
|
||||
|
||||
assertEquals(1, scaled.width)
|
||||
assertEquals(1280, scaled.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scaling guards against degenerate dimensions`() {
|
||||
assertEquals(ScaledSize(1, 1), ChatImageBudget.scaledSize(0, 0))
|
||||
assertEquals(ScaledSize(1, 1280), ChatImageBudget.scaledSize(-3, 5000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data uri has the shape coil and the server both accept`() {
|
||||
val uri = ChatImageBudget.dataUri("image/jpeg", "AAAA")
|
||||
|
||||
assertEquals("data:image/jpeg;base64,AAAA", uri)
|
||||
assertTrue(uri.startsWith("data:image/jpeg;base64,"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
|
||||
/**
|
||||
* 乐观气泡的归一规则(`clientMsgId` 首选、同文本兜底)。
|
||||
*
|
||||
* v0.56 之前只能靠「文本相等」猜,而文本是会变的(发送时被 trim、服务端将来会再加工),
|
||||
* 两条一样的话还会连吃两个气泡。[consumePendingFor] 就是这条规则的唯一实现。
|
||||
*/
|
||||
class PendingReconcileTest {
|
||||
|
||||
private fun pending(localId: String, text: String) = PendingMessage(localId = localId, text = text)
|
||||
|
||||
private fun persisted(
|
||||
id: String = "m1",
|
||||
content: String = "你好",
|
||||
clientMsgId: String? = null,
|
||||
role: String = "user",
|
||||
) = HistoryMessageDto(
|
||||
id = id,
|
||||
sessionId = "web",
|
||||
role = role,
|
||||
content = content,
|
||||
clientMsgId = clientMsgId,
|
||||
createdAt = 0L,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a matching clientMsgId wins even when the persisted text differs`() {
|
||||
val pending = listOf(pending("local-a", "发送时的文本"), pending("local-b", "另一条"))
|
||||
|
||||
val left = consumePendingFor(
|
||||
pending,
|
||||
persisted(content = "服务端加工后的文本", clientMsgId = "local-a"),
|
||||
)
|
||||
|
||||
// 文本对不上也照样命中;另一条纹丝不动。
|
||||
assertEquals(listOf("local-b"), left.map { it.localId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a persisted message without a clientMsgId still falls back to the first equal text`() {
|
||||
val pending = listOf(pending("local-a", "别的话"), pending("local-b", "旧服务端的消息"))
|
||||
|
||||
val left = consumePendingFor(pending, persisted(content = "旧服务端的消息"))
|
||||
|
||||
assertEquals(listOf("local-a"), left.map { it.localId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank clientMsgId counts as absent and uses the text fallback`() {
|
||||
val pending = listOf(pending("local-a", "同文本"))
|
||||
|
||||
val left = consumePendingFor(pending, persisted(content = "同文本", clientMsgId = " "))
|
||||
|
||||
assertTrue(left.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown clientMsgId consumes nothing and does not fall back to text`() {
|
||||
val pending = listOf(pending("local-a", "同文本"))
|
||||
|
||||
// 带键的消息属于**某一次具体的发送**:键对不上就说明它不是这一条(可能是别的端发的),
|
||||
// 此时按文本去吃不等于「对齐」,而是吃掉一个无关的气泡。
|
||||
val left = consumePendingFor(pending, persisted(content = "同文本", clientMsgId = "local-other"))
|
||||
|
||||
assertEquals(listOf("local-a"), left.map { it.localId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two equal-text bubbles are consumed one at a time and never double consumed`() {
|
||||
val pending = listOf(pending("local-a", "重复的话"), pending("local-b", "重复的话"))
|
||||
|
||||
val afterFirst = consumePendingFor(pending, persisted(id = "m1", content = "重复的话", clientMsgId = "local-a"))
|
||||
assertEquals(listOf("local-b"), afterFirst.map { it.localId })
|
||||
|
||||
// 第二条(键对不上任何现存气泡)不该再吃掉 local-b。
|
||||
val afterSecond = consumePendingFor(
|
||||
afterFirst,
|
||||
persisted(id = "m2", content = "重复的话", clientMsgId = "local-a"),
|
||||
)
|
||||
assertEquals(listOf("local-b"), afterSecond.map { it.localId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaying the same persisted message changes nothing the second time`() {
|
||||
val pending = listOf(pending("local-a", "同一条"))
|
||||
|
||||
val once = consumePendingFor(pending, persisted(content = "同一条"))
|
||||
val twice = consumePendingFor(once, persisted(content = "同一条"))
|
||||
|
||||
assertTrue(once.isEmpty())
|
||||
assertTrue(twice.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matching keeps the order of the remaining bubbles`() {
|
||||
val pending = listOf(pending("local-a", "甲"), pending("local-b", "乙"), pending("local-c", "丙"))
|
||||
|
||||
val left = consumePendingFor(pending, persisted(content = "乙", clientMsgId = "local-b"))
|
||||
|
||||
assertEquals(listOf("local-a", "local-c"), left.map { it.localId })
|
||||
assertEquals(listOf("甲", "丙"), left.map { it.text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no match returns the very same list instance`() {
|
||||
val pending = listOf(pending("local-a", "甲"))
|
||||
|
||||
val left = consumePendingFor(pending, persisted(content = "毫不相干", clientMsgId = "local-x"))
|
||||
|
||||
// 无命中时不得重建列表:Compose 的 key 稳定性和「什么都没发生」的语义都靠它。
|
||||
assertSame(pending, left)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the pending input is never mutated`() {
|
||||
val pending = listOf(pending("local-a", "甲"), pending("local-b", "乙"))
|
||||
|
||||
consumePendingFor(pending, persisted(content = "乙", clientMsgId = "local-b"))
|
||||
|
||||
assertEquals(listOf("local-a", "local-b"), pending.map { it.localId })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user