From e5fa1b871f1580cf46721262f79484db095061f0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 21:57:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=A5=91=E7=BA=A6=20v0.63=20?= =?UTF-8?q?=E7=9A=84=20documents[].reason=EF=BC=9A=E6=8A=8A=E3=80=8C?= =?UTF-8?q?=E8=AF=BB=E4=B8=8D=E5=88=B0=E7=9A=84=E5=8E=9F=E5=9B=A0=E3=80=8D?= =?UTF-8?q?=E5=8E=9F=E6=A0=B7=E9=80=81=E5=88=B0=E9=99=84=E4=BB=B6=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E5=BC=B9=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 902b6ee / 契约 v0.63 给 historyMessage.documents[] 的元素加了可选字段 reason: 只在 status == "unreadable" 时下发(Go omitempty,extracted 时整个键缺席),内容与后端 注入模型清单里的那 10 条是同一份字符串,最长的一条 40+ 字。 改了什么 - DocumentRefDto 加 reason: String? = null。documents[] 在 WS 握手 history、 message_persisted、GET /messages 三处共用这一个 DTO,所以一处解析三条路全覆盖。 KDoc 写明三条口径:可选、只在 unreadable 时下发、是后端原话所以客户端原样显示, 且绝不做整句相等判断(需要分支只允许用稳定子串)。 - DocumentChipModel 加 reason,由 DocumentChips.of(ref) 取 ref.reason 去掉首尾空白后 非空才留,否则归一成 null——一句空话会顶掉弹层里的通用说明,比没有更坏。 芯片外观一个字没改:detail / statusLabel 保持现状(unreadable 仍显示「读不到」)。 理由:那句话太长,塞进一行芯片只会被 Ellipsis 切掉半句,比不显示更糟。 - DocumentPreviewGate.plan 增加 reason 参数:非空白时直接作为正文区那句说明原样返回 (NotText(reason)),压过本端按 mime / 扩展名的裁决;DocumentTarget.previewPlan 顺着 chips.reason 把它带进裁决。预览弹层本身一行代码都没改——它渲染的就是 plan.message 那句话,所以 reason 从 DTO 到用户看见的那句话整条链路上没有任何改写或截断。 - 配套单测 17 条。 为什么这么改 - 服务端那句原因已经断言「无法抽取正文」,它比本端按类型编的通用说明(只知道类型、 不知道到底为什么)准确;替它改写、归纳或截断就是丢掉服务端唯一一次说清原因的机会。 - 它也不该与正文并存:服务端说正文抽不出来,本端再把字节当 UTF-8 渲染,产出的正是 正文闸要消灭的「看着像文件坏了」。 - reason 缺席时(契约 v0.63 之前的历史消息)产出与改之前逐字相同:兜底那句话、判据、 文案全都不动。 测试(新增 17 条) - DocumentDtoTest +5:unreadable 带 reason 逐字解出;extracted 元素没有该键 -> null; v0.62 风格的缺键载荷 -> null(不解码失败、不替服务端编一句话);清单外的未知分类原样 保留;显式 null -> null。 - DocumentChipTest +5:reason 带进模型;句中标点与换行原样、只去首尾空白;空白串 -> null;绝不进芯片副行(detail 逐字不变);待发附件恒为 null。 - DocumentPreviewTest +7:reason 逐字成为正文区那句话(含未知分类,不丢不改不换兜底); 只 trim 一次再原样显示;缺席时与改前逐字相同(拿 refusalMessage 对比); 空白 reason 不改任何行为;text mime + reason 不再假装有正文。 - assembleDebug 通过;testDebugUnitTest 848 个测试 0 失败 0 错误 0 跳过。 已知代价(已写进 KDoc,供人拍板) 服务端判为读不到、但字节其实下得动的附件(例如超限的纯文本)会失去 App 内预览, 「用其它应用打开」与「保存到下载目录」两条出口照旧完整可用——与「未知 mime 判为非文本」 是同一笔不对称账。 --- .../yeij/cyrene/data/api/dto/DocumentDto.kt | 20 ++++- .../yeij/cyrene/domain/chat/DocumentChip.kt | 14 +++ .../cyrene/domain/chat/DocumentPreview.kt | 29 ++++++- .../cyrene/ui/chat/DocumentPreviewDialog.kt | 5 ++ .../cyrene/data/api/dto/DocumentDtoTest.kt | 65 ++++++++++++++ .../cyrene/domain/chat/DocumentChipTest.kt | 57 +++++++++++- .../cyrene/domain/chat/DocumentPreviewTest.kt | 87 ++++++++++++++++++- 7 files changed, 269 insertions(+), 8 deletions(-) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/DocumentDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/DocumentDto.kt index d11eac1..198bf06 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/DocumentDto.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/DocumentDto.kt @@ -25,6 +25,8 @@ import kotlinx.serialization.Serializable * 3. 下行/历史元素只回**引用与统计,绝不回正文**(吸取 `G3`「base64 原样落库、每页历史重传」 * 的教训)。`status:"unreadable"` 才是「读不到这个文件」的可观测信号,**不是** `truncated` * (`truncated` 只说明注入正文被预算截断,正文照样进得了上下文)。 + * **契约 v0.63(后端 `902b6ee`)在此之上加了可选 [DocumentRefDto.reason]**:读不到时随 + * `status:"unreadable"` 一起下发的**服务端原话**,客户端原样显示(详见该字段的 KDoc)。 * * ## 客户端必须两种都吃的一件事 * 「0 个附件」这一形状在后端**尚未定死**(回执五原文:期望「不出现或空数组」,S3.5 回报里定死)。 @@ -58,8 +60,8 @@ data class DocumentUploadResponse( * 下行/历史则带上全部统计字段。 * * 字段可出现性(后端 `history.DocumentRef` 的 `omitempty` 口径,逐字段核对过): - * `name` / `mime` / `size` **恒在**;`chars` / `truncated` / `status` 为 Go 的 `omitempty` - * —— 零值(0 / false / "")时**整键省略**,所以取值一律给默认值,解码不会因缺键失败。 + * `name` / `mime` / `size` **恒在**;`chars` / `truncated` / `status` / `reason` 为 Go 的 `omitempty` + * —— 零值(0 / false / "" / nil)时**整键省略**,所以取值一律给默认值,解码不会因缺键失败。 */ @Serializable data class DocumentRefDto( @@ -80,4 +82,18 @@ data class DocumentRefDto( * 未知值**原样保留**(不丢弃、不报错、不占位符),由客户端决定如何显示。 */ val status: String = "", + /** + * **读不到的原因**(契约 v0.63 新增,后端 `902b6ee`):`status == "unreadable"` 时下发的 + * 那句**服务端原话**;`status == "extracted"` 时**整键缺席**,因此是可选字段。 + * + * 两句纪律: + * 1. **原样显示**。[reason] 与后端**注入模型清单里的那 10 条是同一份字符串**(逐字相同, + * 最长的一条 40+ 字),客户端**不替服务端改写、不截断、不归纳**成自己的话—— + * 本端能编的只有像 [top.yeij.cyrene.domain.chat.DocumentPreviewGate.refusalMessage] + * 那样「按类型泛泛说一句」,比这里的原因粗得多; + * 2. **绝不做整句相等判断**。分类若真的需要分支,只允许用**稳定子串**(如「需要 OCR」)—— + * 整句比对会在后端改一个字时静默失效,且失败的样子是「显示成兜底那句话」, + * 看起来完全正常。 + */ + val reason: String? = null, ) diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentChip.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentChip.kt index 8178282..b5d4647 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentChip.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentChip.kt @@ -27,6 +27,15 @@ data class DocumentChipModel( val truncatedLabel: String?, /** `status == "unreadable"`:这是唯一一个「昔涟读不到」的信号,UI 据此给灰态。 */ val unreadable: Boolean, + /** + * 服务端「读不到」的**原话**(`documents[].reason`,契约 v0.63;只在 `unreadable` 时下发)。 + * 已去掉首尾空白,空白串与缺席一律归一成 `null`。 + * + * ⚠️ **刻意不进 [detail]**:这句话有 40+ 字,塞进芯片那一行只会被 `Ellipsis` 切掉半句, + * 比不显示更糟。它要出现的地方是**用户点开附件后的预览弹层**,见 + * [DocumentTarget.previewPlan]。 + */ + val reason: String? = null, ) { /** 芯片副行:`MD · 12 KB · 8,000 字 · 已读入`(缺哪项少哪项,全缺就是空串)。 */ val detail: String @@ -54,6 +63,9 @@ object DocumentChips { statusLabel = statusLabel(ref.status), truncatedLabel = if (ref.truncated) TRUNCATED else null, unreadable = DocumentStatus.isUnreadable(ref.status), + // 首尾空白去掉;只剩空白(或本就缺席)时归一成 null——「有一句空话」比没有更坏: + // 弹层会拿它顶掉那句通用说明,于是用户看到一块什么都没有的正文区。 + reason = ref.reason?.trim()?.takeIf { it.isNotEmpty() }, ) /** 一份**待发**附件 → 芯片模型:还没有服务端结论,所以状态与字数为空。 */ @@ -65,6 +77,8 @@ object DocumentChips { statusLabel = null, truncatedLabel = null, unreadable = false, + // 待发附件还没有任何服务端结论,也就没有「读不到的原因」可言。 + reason = null, ) /** 扩展名大写;没有扩展名或扩展名不像扩展名时回「文件」(不猜类型)。 */ diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentPreview.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentPreview.kt index ab0853e..135c5c6 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentPreview.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentPreview.kt @@ -60,10 +60,13 @@ data class DocumentTarget( * `mime`,本地待发附件**没有**服务端 `mime`(那时还没上传过),只能按显示名兜底。 * 本地那条**刻意不问** `ContentResolver.getType`——平台给 `.rs` 这类文件常报 * `application/octet-stream` 或空,拿它当判据会把一批纯文本文件挡在预览之外。 + * + * 服务端下发的「读不到的原因」([DocumentChipModel.reason],契约 v0.63)在这里**顺路带进 + * 裁决**:它是这句话唯一的去处(芯片那一行放不下 40+ 字),详见 [DocumentPreviewGate.plan]。 */ fun previewPlan(): DocumentPreviewPlan = when (val origin = source) { - is DocumentSource.Server -> DocumentPreviewGate.plan(origin.mime, origin.name) - is DocumentSource.Local -> DocumentPreviewGate.plan("", origin.displayName) + is DocumentSource.Server -> DocumentPreviewGate.plan(origin.mime, origin.name, chips.reason) + is DocumentSource.Local -> DocumentPreviewGate.plan("", origin.displayName, chips.reason) } /** @@ -161,6 +164,9 @@ sealed interface DocumentPreviewPlan { /** * 非文本类(PDF 这类二进制内容):**绝不把字节当 UTF-8 渲染**(那会是一整屏乱码), * 也**不假装加载失败**(这不是失败,字节取回来了,只是本端不预览它)。 + * + * [message] 有两种来源,都是**原样显示**:本端按类型写的那句([DocumentPreviewGate.refusalMessage]), + * 或服务端下发的「读不到的原因」原话(契约 v0.63 的 `documents[].reason`)。 */ data class NotText(val message: String) : DocumentPreviewPlan } @@ -175,6 +181,8 @@ sealed interface DocumentPreviewPlan { * 实际是本端拿错了渲染方式。 * * ## 判据(优先服务端,退而扩展名) + * 0. `reason` 非空白 ⇒ **直接就是正文区那句说明,原样显示**(契约 v0.63:服务端说读不到时 + * 下发的原话,比本端按类型编的那句准确)。它压过下面三条,代价见 [plan] 的 KDoc; * 1. `mime` 非空 ⇒ **只看服务端那一次字节嗅探**(契约 §1.2:嗅探是唯一裁决权): * 以 `text/` 开头的判为文本,其余(`application/pdf` / `application/octet-stream` / 图片型 …) * 一律判为**非文本**; @@ -194,8 +202,23 @@ object DocumentPreviewGate { /** * @param mime 服务端 DTO 里的 `mime`(嗅探所得基类型);待发附件传空串。 * @param name 服务端存储名或待发附件的显示名——**只在 [mime] 为空时才看它**。 + * @param reason 服务端下发的「读不到的原因」原话(`documents[].reason`,契约 v0.63; + * 已由 [top.yeij.cyrene.domain.chat.DocumentChips] 去掉首尾空白并在空白时归一成 `null`), + * 缺席 = 服务端没给,或这条引用本来就不是 `unreadable`。 + * + * [reason] 非空白时**它直接就是正文区那句说明,原样显示**,不再走本端按 [mime] / 扩展名的 + * 裁决——理由是这句话已经断言了「无法抽取正文」: + * - 它比本端那句通用说明([refusalMessage]:只知道类型,不知道到底为什么)**准确**, + * 替它改写或归纳就是丢掉服务端唯一一次说清原因的机会; + * - 它也不该与正文并存:服务端说正文抽不出来,本端再把字节当 UTF-8 渲染,产出的正是这道 + * 闸要消灭的「看着像文件坏了」。 + * + * 代价说清楚:这条会让**服务端判为读不到、但字节其实下得动**的附件失去 App 内预览 + * (例如超限的纯文本)。两条出口(用其它应用打开 / 保存到下载目录)照旧完整可用, + * 与下面「未知 mime 判为非文本」是同一笔不对称账。 */ - fun plan(mime: String, name: String): DocumentPreviewPlan { + fun plan(mime: String, name: String, reason: String? = null): DocumentPreviewPlan { + if (!reason.isNullOrBlank()) return DocumentPreviewPlan.NotText(reason) val base = baseType(mime) if (base.isEmpty()) return extensionPlan(name) return if (base.startsWith(TEXT_PREFIX)) DocumentPreviewPlan.Text else refuse(base) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt index 78ddfc0..51c602c 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/DocumentPreviewDialog.kt @@ -70,6 +70,11 @@ data class DocumentPreviewNotice(val message: String, val error: Boolean = false * 只留 [DocumentPreviewPlan.NotText.message] 那句人话:**这既不是加载失败**(字节是取回来的, * 两条出口照旧可用),也不是「文件是空的」。 * + * 那句话有两种来源,本弹层**一视同仁地原样渲染**:本端按类型写的那句,以及服务端说「读不到」 + * 时下发的**原因原话**(契约 v0.63 的 `documents[].reason`,判定见 + * [top.yeij.cyrene.domain.chat.DocumentPreviewGate.plan])——本层不做任何改写或截断, + * 它拿到的 `message` 就是要显示的那句话。 + * * ## 正文为什么是 `LazyColumn` + 分块 * 上限是 5000 行 / 256 KB,整段塞进一个 `Text` 会让打开弹层的那一刻卡在排版上。切成 * [top.yeij.cyrene.domain.chat.DocumentPreviewLimits.LINES_PER_CHUNK] 行一块交给 `LazyColumn`, diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/DocumentDtoTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/DocumentDtoTest.kt index e0f81be..e43d789 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/DocumentDtoTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/DocumentDtoTest.kt @@ -22,6 +22,8 @@ import top.yeij.cyrene.domain.chat.DocumentStatus * `5b2b385`(S3.5 文档多值化:`history.go` 的 `historyMessage.Documents` 带 `omitempty`、 * `history.DocumentRef` 的 `omitempty` 口径、`history_document_test.go` 钉住的元素字段集 * `chars,mime,name,size,status,truncated` 与它用的真实取值)。 + * **契约 v0.63(后端 `902b6ee`)在此之上加了 `reason`**(只在 `status == "unreadable"` 时下发的 + * 服务端原话):那几条用例同样按「缺键 → `null`、有值 → 逐字原样」钉住,**不做整句相等判断**。 * **没有 v0.59 的真实抓包(形状按 api-client §1.2 与后端实现逐条核对)**:这里的 JSON 是按上述实现形状逐字节手写的(唯一确认过的真实取值 * 来自后端自己的测试夹具)。 * @@ -172,6 +174,69 @@ class DocumentDtoTest { assertEquals("", doc.status) } + // —— 契约 v0.63(后端 `902b6ee`):`documents[].reason` —— + + /** + * 后端注入模型清单里那 10 条中的一条,**真实串就是这么长**:逐字照抄,不做任何简化。 + * 这里的用例一律拿它整串比对,就是为了钉住「客户端不改写、不截断」。 + */ + private val ocrReason = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)" + + @Test + fun `an unreadable reference carries the server reason verbatim`() { + val doc = message( + withDocuments( + """[{"name":"$name","mime":"application/pdf","size":12345,"status":"unreadable",""" + + """"reason":"$ocrReason"}]""", + ), + ).documents.single() + + assertEquals(DocumentStatus.UNREADABLE, doc.status) + assertEquals(ocrReason, doc.reason) + } + + @Test + fun `an extracted reference has no reason key at all`() { + // 服务端只在 status == "unreadable" 时填 `reason`(Go omitempty):extracted 的元素里 + // 整个键不存在 ⇒ 解出来必须是 `null`,**不是空串**——空串会顶掉弹层里那句通用说明。 + val doc = message( + withDocuments( + """[{"name":"$name","mime":"text/plain","size":7,"chars":7,"status":"extracted"}]""", + ), + ).documents.single() + + assertNull(doc.reason) + } + + @Test + fun `a v0_62 style payload without the reason key still decodes`() { + // 契约 v0.63 之前的历史消息里没有这个键:解码不许失败,也不许替服务端编一句话。 + val doc = message(withDocuments("""[{"name":"$name","status":"unreadable"}]""")).documents.single() + + assertNull(doc.reason) + } + + @Test + fun `a reason outside the ten known ones is preserved verbatim`() { + // 分类可以开放,客户端**不做整句相等判断**:没见过的那句照样原样留着。 + val reason = "无法抽取正文:这条分类客户端从没见过" + + val doc = message( + withDocuments("""[{"name":"$name","status":"unreadable","reason":"$reason"}]"""), + ).documents.single() + + assertEquals(reason, doc.reason) + } + + @Test + fun `an explicit null reason decodes as null`() { + val doc = message( + withDocuments("""[{"name":"$name","status":"unreadable","reason":null}]"""), + ).documents.single() + + assertNull(doc.reason) + } + // —— chat 请求:带附件与不带附件 —— @Test diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentChipTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentChipTest.kt index 625fa26..06f4da8 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentChipTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentChipTest.kt @@ -14,7 +14,10 @@ import top.yeij.cyrene.data.api.dto.DocumentRefDto * 1. **状态与截断不许互相顶替**——`unreadable` 才是「读不到」,`truncated` 只说明注入被预算 * 截断(正文照样进了上下文),两句文案必须分开; * 2. **未知 `status` 原样显示**(开放集合:不丢弃、不报错、不占位符); - * 3. 服务端**没报**的字段(`chars` 缺席、体积为 0/未知)不要编出一个数字来。 + * 3. 服务端**没报**的字段(`chars` 缺席、体积为 0/未知)不要编出一个数字来; + * 4. 服务端下发的「读不到的原因」(契约 v0.63 的 `documents[].reason`)**原样**带进模型 + * (只去首尾空白;空白串归一成 `null`,因为一句空话会顶掉弹层里的通用说明), + * 且**绝不进芯片副行**——40+ 字在那里只会被切掉半句。 */ class DocumentChipTest { @@ -25,6 +28,7 @@ class DocumentChipTest { chars: Int? = 8_000, truncated: Boolean = false, status: String = DocumentStatus.EXTRACTED, + reason: String? = null, ) = DocumentRefDto( name = name, mime = mime, @@ -32,6 +36,7 @@ class DocumentChipTest { chars = chars, truncated = truncated, status = status, + reason = reason, ) // —— 类型 —— @@ -122,6 +127,56 @@ class DocumentChipTest { assertEquals("MD · 12.1 KB", chip.detail) } + // —— 服务端下发的「读不到的原因」(契约 v0.63)—— + + @Test + fun `the server reason rides along in the model`() { + val reason = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)" + + assertEquals(reason, DocumentChips.of(ref(status = DocumentStatus.UNREADABLE, reason = reason)).reason) + } + + @Test + fun `the reason keeps its own punctuation and newlines`() { + // 「原样」就是原样:只去首尾空白,句中标点与换行一个字都不动(这条串不是给本端归纳的)。 + val reason = "无法抽取正文:第一行(括注);第二行\n 带全角空格的细节" + + assertEquals( + reason, + DocumentChips.of(ref(status = DocumentStatus.UNREADABLE, reason = "\n $reason \n")).reason, + ) + } + + @Test + fun `a blank reason is nothing, not an empty sentence`() { + // 空串会顶掉弹层里那句通用说明,用户看到的正文区就成了空的——比没有原因更坏。 + assertNull(DocumentChips.of(ref(reason = null)).reason) + assertNull(DocumentChips.of(ref(reason = "")).reason) + assertNull(DocumentChips.of(ref(reason = " ")).reason) + assertNull(DocumentChips.of(ref(reason = "\n\t\n")).reason) + } + + @Test + fun `the reason never leaks into the chip line`() { + // 40+ 字塞进芯片那一行只会被 Ellipsis 切掉半句,比不显示更糟:`detail` 逐字不变。 + val chip = DocumentChips.of( + ref( + chars = null, + status = DocumentStatus.UNREADABLE, + reason = "无法抽取正文:PDF 已加密(当前不支持)", + ), + ) + + assertEquals("MD · 12.1 KB · 读不到", chip.detail) + assertTrue(chip.unreadable) + } + + @Test + fun `a draft attachment has no reason at all`() { + // 待发附件还没有任何服务端结论,也就没有「读不到的原因」可言。 + assertNull(DocumentChips.of(DraftDocument(uri = "content://doc/1", displayName = "报告.md")).reason) + } + // —— 副行拼接 —— @Test diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentPreviewTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentPreviewTest.kt index a6a90e0..ddb0f2c 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentPreviewTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentPreviewTest.kt @@ -21,14 +21,19 @@ import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException * 3. 没有任何 App 能开时**如实告知**并指路保存,**不静默失败**; * 4. 来源判定只有一处([DocumentTarget.of]),服务端来源**只能用存储名**当文件名; * 5. App 内**正文**只预览文本类(v0.61 的 `.pdf` 因此不会被当 UTF-8 渲染成一屏乱码), - * 且非文本那句人话**不许读起来像失败**。 + * 且非文本那句人话**不许读起来像失败**; + * 6. 服务端说「读不到」时下发的**原因原话**(契约 v0.63 的 `documents[].reason`)优先于本端 + * 按类型编的那句,且**逐字**显示(没见过的原因也照原样,不做整句相等判断);它缺席时 + * 产出与加入该字段之前逐字相同。 */ class DocumentPreviewTest { private fun ref( name: String = "0123456789abcdef0123456789abcdef.md", mime: String = "text/plain", - ) = DocumentRefDto(name = name, mime = mime) + status: String = "", + reason: String? = null, + ) = DocumentRefDto(name = name, mime = mime, status = status, reason = reason) private fun filler(size: Int) = ByteArray(size) { 'a'.code.toByte() } @@ -293,6 +298,84 @@ class DocumentPreviewTest { assertTrue(messages.toString(), messages.none { it.contains("失败") || it.contains("空文件") || it.contains("是空的") }) } + // —— 服务端下发的「读不到的原因」(契约 v0.63:`documents[].reason`)—— + + /** + * 后端那 10 条里的一条,**逐字照抄**(真实串就是这么长)。 + * 用整串相等断言是为了钉住「不改写、不截断、不归纳」——**不是**拿它当实现里的判据。 + */ + private val ocrReason = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)" + + @Test + fun `the server reason becomes the sentence in the body area, verbatim`() { + val plan = DocumentTarget.of( + ref( + name = "0123456789abcdef0123456789abcdef.pdf", + mime = "application/pdf", + status = DocumentStatus.UNREADABLE, + reason = ocrReason, + ), + ).previewPlan() + + assertEquals(DocumentPreviewPlan.NotText(ocrReason), plan) + } + + @Test + fun `a reason outside the ten known ones is shown as it came`() { + // 客户端**不做整句相等判断**:没见过的原因照样原样进正文区,不丢、不改、不换兜底。 + val reason = "无法抽取正文:这条分类客户端从没见过(契约同版的第 11 种)" + + val plan = DocumentTarget.of(ref(status = DocumentStatus.UNREADABLE, reason = reason)).previewPlan() + + assertEquals(DocumentPreviewPlan.NotText(reason), plan) + } + + @Test + fun `the reason is trimmed once and then shown as it is`() { + // 首尾空白由 DocumentChips 去掉(免得弹层里那句话顶着一截空行),句中标点原样。 + val reason = "无法抽取正文:PDF 损坏或无法解析;请重传(原文如此)" + + val plan = DocumentTarget.of( + ref(status = DocumentStatus.UNREADABLE, reason = " $reason "), + ).previewPlan() + + assertEquals(DocumentPreviewPlan.NotText(reason), plan) + } + + @Test + fun `without a reason the body area keeps the very same sentence as before`() { + // 契约 v0.63 之前的历史消息不发 reason:这条路的产出必须与加入该字段之前逐字相同。 + val plan = DocumentPreviewGate.plan("application/pdf", "0123456789abcdef0123456789abcdef.pdf") + + assertEquals(DocumentPreviewPlan.NotText(DocumentPreviewGate.refusalMessage("PDF")), plan) + } + + @Test + fun `a blank reason changes nothing at all`() { + val blank = DocumentPreviewGate.plan("application/pdf", "a.pdf", " ") + + assertEquals(DocumentPreviewGate.plan("application/pdf", "a.pdf"), blank) + // 尤为重要的是别把它当成一句话显示出来:那会得到一块什么都没有的正文区。 + assertTrue((blank as DocumentPreviewPlan.NotText).message.isNotBlank()) + assertEquals(DocumentPreviewPlan.Text, DocumentPreviewGate.plan("text/plain", "a.md", "\n")) + } + + @Test + fun `a text mime with a reason no longer pretends to be a body`() { + // 服务端已经说了「无法抽取正文」,本端再把字节当 UTF-8 渲染就是同一类「看着像文件坏了」。 + val reason = "无法抽取正文:读取或解析失败" + + assertEquals(DocumentPreviewPlan.NotText(reason), DocumentPreviewGate.plan("text/plain", "a.md", reason)) + } + + @Test + fun `the reason takes precedence over the extension fallback used by drafts`() { + val reason = "无法抽取正文:本期不解析 Office 文档" + + assertEquals(DocumentPreviewPlan.NotText(reason), DocumentPreviewGate.plan("", "报告.pdf", reason)) + assertEquals(DocumentPreviewPlan.NotText(reason), DocumentPreviewGate.plan("", "笔记.md", reason)) + } + // —— 没有 App 能开 —— @Test