merge: W6 — 文档进对话数据层(/documents + chat.documents,v0.59 形状)
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user