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:
AskaEth committed 2026-10-04 16:57:22 +08:00
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 })
}
}