merge: W6 — 文档进对话数据层(/documents + chat.documents,v0.59 形状)

This commit is contained in:
AskaEth committed 2026-10-04 18:08:20 +08:00
commit a80dac7a7e
13 files changed
+1498 -1

No files matched your search

@@ -0,0 +1,239 @@
package top.yeij.cyrene.domain.chat
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.data.api.dto.DocumentRefDto
/**
* 「该不该走文件」的判定矩阵与 `chat.documents` 载荷构造(纯函数)。
*
* **形状来源**:`02-前后端协同事项.md` **回执五**(契约 v0.59 尚未发布)——
* 「≤5 条」「只收服务端存储名」「文档一律走 REST」「WS 上行帧 4096 字节硬上限仍在」;
* 数值另对齐后端实现与测试:`chat_documents.go` 的 `maxChatDocuments = 5`、
* `wsReadLimit = 4096`(契约 v0.56 明确记载)。
*
* **本端自定的界线在测试里标明了**:[DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT] 是客户端
* 「正文量级不像一条消息」的参照(借 `PATCH /api/v1/messages/{id}` 的 4000 rune 上限;
* **`/chat` 本身没有正文长度上限**),所以这里同时钉住「超 WS 帧限 ⇒ 只是走 REST,**不是**
* 转文件」——那条界线属 [ChatSendRouting],本对象不另立阈值。
*/
class DocumentDeliveryTest {
private fun file(name: String = "note.txt", size: Int = 16) =
DocumentFile(fileName = name, bytes = ByteArray(size))
private fun files(count: Int) = List(count) { file(name = "note-$it.txt") }
// —— 正文界线 ——
@Test
fun `empty content with no attachments is an inline send`() {
// 纯空消息:调用方在更外层已经拦掉(text+image 至少一项),这里不该把它变成附件或报错。
assertEquals(DocumentPlan.Inline, DocumentDelivery.plan("", emptyList()))
}
@Test
fun `a short message stays inline`() {
assertEquals(DocumentPlan.Inline, DocumentDelivery.plan("你好", emptyList()))
}
@Test
fun `the rune threshold is inclusive - exactly the limit still goes inline`() {
val atLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT)
assertEquals(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT, DocumentDelivery.runeCount(atLimit))
assertFalse(DocumentDelivery.textNeedsFile(atLimit))
assertEquals(DocumentPlan.Inline, DocumentDelivery.plan(atLimit, emptyList()))
}
@Test
fun `one rune over the limit turns the text into a document`() {
val overLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1)
assertTrue(DocumentDelivery.textNeedsFile(overLimit))
assertEquals(
DocumentPlan.AsDocuments(files = emptyList(), textAsDocument = true),
DocumentDelivery.plan(overLimit, emptyList()),
)
}
@Test
fun `rune counting follows code points so an emoji counts as one`() {
// UTF-16 里 emoji 是 2 个 char,Go 的 rune 与 Unicode 码点都算 1 —— 用 `length` 会提前一倍转文件。
val emoji = "🎐"
assertEquals(2, emoji.length)
assertEquals(1, DocumentDelivery.runeCount(emoji))
val overLimit = emoji.repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1)
assertEquals(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1, DocumentDelivery.runeCount(overLimit))
assertTrue(DocumentDelivery.textNeedsFile(overLimit))
}
// —— 「超 WS 帧限 = 换通道」与「超 rune 界线 = 换形态」是两件事 ——
@Test
fun `a text over the frame limit but under the rune threshold goes REST, not to a file`() {
// 4000 个汉字 = 12000 字节,远超 WS 文字的 3584 字节:必须走 REST(否则服务端**直接断连**),
// 但按 rune 它仍是一条消息 —— 转文件会平白改掉一条已验证过的路径。
val text = "昔".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT)
assertTrue(DocumentDelivery.textNeedsRest(text))
assertFalse(DocumentDelivery.textNeedsFile(text))
assertEquals(DocumentPlan.Inline, DocumentDelivery.plan(text, emptyList()))
assertEquals(
SendChannel.Rest,
ChatSendRouting.channelFor(text, hasImage = false, hasDocuments = false),
)
}
@Test
fun `the REST boundary is the frame limit, inclusive on the websocket side`() {
val atLimit = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES)
val overLimit = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES + 1)
assertFalse(DocumentDelivery.textNeedsRest(atLimit))
assertTrue(DocumentDelivery.textNeedsRest(overLimit))
}
@Test
fun `attachments always go over REST even when the text would fit the websocket`() {
// 文档一律走 REST:上传换引用这条链路整个压在同一条通道上。
assertEquals(
SendChannel.Rest,
ChatSendRouting.channelFor("嗨", hasImage = false, hasDocuments = true),
)
// 默认参数让既有调用方(无附件)行为逐字不变。
assertEquals(
SendChannel.Ws,
ChatSendRouting.channelFor("嗨", hasImage = false),
)
assertEquals(DocumentDelivery.MAX_ATTACHMENTS_PER_TURN, 5)
}
// —— 附件数 ——
@Test
fun `one attachment is enough to switch to the document channel`() {
val one = files(1)
assertEquals(
DocumentPlan.AsDocuments(files = one, textAsDocument = false),
DocumentDelivery.plan("看看这个", one),
)
}
@Test
fun `exactly the maximum number of attachments is accepted`() {
val five = files(DocumentDelivery.MAX_ATTACHMENTS_PER_TURN)
assertEquals(5, five.size)
assertEquals(
DocumentPlan.AsDocuments(files = five, textAsDocument = false),
DocumentDelivery.plan("", five),
)
}
@Test
fun `one attachment over the maximum is explicitly rejected, never trimmed`() {
// 静默只发前 5 个 = 用户以为 6 份都传上去了。必须让调用方给出提示。
val six = files(DocumentDelivery.MAX_ATTACHMENTS_PER_TURN + 1)
assertEquals(
DocumentPlan.Rejected(DocumentRejection.TooManyAttachments),
DocumentDelivery.plan("看看这些", six),
)
}
@Test
fun `the attachment limit is checked before anything else`() {
// 超限 + 长正文:回复必须是「附件太多」,不能落进「把正文转文件」那条分支。
val overLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1)
assertEquals(
DocumentPlan.Rejected(DocumentRejection.TooManyAttachments),
DocumentDelivery.plan(overLimit, files(DocumentDelivery.MAX_ATTACHMENTS_PER_TURN + 1)),
)
}
@Test
fun `a long text plus attachments asks for both`() {
val overLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1)
val two = files(2)
assertEquals(
DocumentPlan.AsDocuments(files = two, textAsDocument = true),
DocumentDelivery.plan(overLimit, two),
)
}
@Test
fun `a text at the limit plus attachments keeps the text inline in the request`() {
val atLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT)
val two = files(2)
assertEquals(
DocumentPlan.AsDocuments(files = two, textAsDocument = false),
DocumentDelivery.plan(atLimit, two),
)
}
// —— 载荷构造 ——
@Test
fun `the payload carries only the storage name`() {
val names = listOf("0123456789abcdef0123456789abcdef.txt")
val payload = DocumentDelivery.payload(names)!!
assertEquals(1, payload.size)
assertEquals("0123456789abcdef0123456789abcdef.txt", payload.single().name)
// 上行只发 name:其它字段留默认值(`encodeDefaults = false` 于是整键省略)。
assertEquals("", payload.single().mime)
assertEquals(0L, payload.single().size)
}
@Test
fun `the payload accepts exactly five names`() {
val names = List(5) { "0123456789abcdef0123456789abcdef$it.txt" }
assertEquals(5, DocumentDelivery.payload(names)!!.size)
}
@Test
fun `the payload refuses six names instead of trimming`() {
val names = List(6) { "0123456789abcdef0123456789abcdef$it.txt" }
assertNull(DocumentDelivery.payload(names))
}
@Test
fun `the payload refuses a blank name instead of dropping it`() {
// 丢掉空名字 = 少发一份附件且双方都不知道;服务端对非法/不存在的名字回 404,客户端也不能静默裁。
assertNull(DocumentDelivery.payload(listOf("0123456789abcdef0123456789abcdef.txt", " ")))
}
@Test
fun `an empty payload list stays empty`() {
assertEquals(emptyList<DocumentRefDto>(), DocumentDelivery.payload(emptyList()))
}
// —— 长正文落成 .txt ——
@Test
fun `a text becomes a utf8 txt file the server whitelist accepts`() {
val file = DocumentDelivery.textAsFile("昔涟")
assertEquals("message.txt", file.fileName)
assertTrue("服务端按扩展名判白名单,必须带 .txt", file.fileName.endsWith(".txt"))
assertEquals("昔涟".toByteArray(Charsets.UTF_8).size.toLong(), file.sizeBytes)
assertEquals("昔涟", String(file.bytes, Charsets.UTF_8))
}
@Test
fun `the txt file name can be overridden for the share intake`() {
assertEquals("报告.md", DocumentDelivery.textAsFile("正文", fileName = "报告.md").fileName)
}
}
@@ -0,0 +1,107 @@
package top.yeij.cyrene.domain.chat
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.CyreneException
/**
* 文档错误码 → 可分支失败类型的映射(纯函数)。
*
* **来源**:`02-前后端协同事项.md` **回执五**(契约 v0.59 尚未发布)§一 S1 的四个错误码表,
* 另核过后端实现里的常量(`documents.go` 的 `documentUnsupportedCode` /
* `documentTooLargeCode` / `documentNotFoundCode`、`chat_documents.go` 的
* `tooManyDocumentsCode` / `documentsUnavailableCode`)。文案一律用**服务端产出的原文**,
* 客户端不自己编数字。
*/
class DocumentFailureTest {
private fun server(code: String, httpStatus: Int, message: String = "服务端的话") =
CyreneException(CyreneError.Server(code = code, message = message, httpStatus = httpStatus))
@Test
fun `unsupported_media_type maps to a type problem the user can only fix by changing the file`() {
val failure = server("unsupported_media_type", 415, "扩展名不在白名单内").toDocumentFailure()
assertEquals(DocumentFailure.UnsupportedType("扩展名不在白名单内"), failure)
}
@Test
fun `document_too_large maps to the size branch and keeps the server numbers`() {
val failure = server("document_too_large", 413, "文档过大:上限 20.0 MiB").toDocumentFailure()
assertEquals(DocumentFailure.TooLarge("文档过大:上限 20.0 MiB"), failure)
}
@Test
fun `too_many_documents maps to the count branch`() {
val failure = server("too_many_documents", 400, "单回合附件最多 5 条,本次 6 条").toDocumentFailure()
assertEquals(DocumentFailure.TooMany("单回合附件最多 5 条,本次 6 条"), failure)
}
@Test
fun `document_not_found maps to the missing branch`() {
val failure = server("document_not_found", 404, "文档不存在").toDocumentFailure()
assertEquals(DocumentFailure.NotFound("文档不存在"), failure)
}
@Test
fun `document_unreadable is its own branch because it is not an upload failure`() {
// 只在显式索取正文时出现(如 document-read):与「上传被拒」是不同的用户动作。
val failure = server("document_unreadable", 400, "正文读不出来").toDocumentFailure()
assertEquals(DocumentFailure.Unreadable("正文读不出来"), failure)
}
@Test
fun `401 and 403 both mean the session cannot do this`() {
assertEquals(
DocumentFailure.Forbidden,
CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized")).toDocumentFailure(),
)
assertEquals(
DocumentFailure.Forbidden,
server("forbidden", 403).toDocumentFailure(),
)
}
@Test
fun `an unmapped server code keeps its message instead of being swallowed`() {
// 后端确有这个码:“文档解析器未接线”(500)。不认识也要原样带出来,绝不静默当成成功。
val failure = server("chat_documents_unavailable", 500, "文档解析器未接线").toDocumentFailure()
assertEquals(DocumentFailure.Other("文档解析器未接线"), failure)
}
@Test
fun `a local network failure falls back to the client message`() {
val failure = CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)).toDocumentFailure()
assertTrue("实际 $failure", failure is DocumentFailure.Other)
assertEquals("连接超时,服务器没有响应", failure.message)
}
@Test
fun `a throwable that is not a cyrene error still gets a message`() {
val failure = IllegalStateException("boom").toDocumentFailure()
assertEquals(DocumentFailure.Other("文档操作失败,请稍后重试"), failure)
}
@Test
fun `the five document codes stay five distinct branches`() {
// 这条是给「日后有人图省事把它们合成一个」留的反向钉子。
val branches = listOf(
server("unsupported_media_type", 415).toDocumentFailure(),
server("document_too_large", 413).toDocumentFailure(),
server("too_many_documents", 400).toDocumentFailure(),
server("document_not_found", 404).toDocumentFailure(),
server("document_unreadable", 400).toDocumentFailure(),
)
assertEquals(5, branches.toSet().size)
}
}