Compare commits
4
Commits
e4915a6904
...
1794e53d1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1794e53d1c | ||
|
|
2d382b11cd | ||
|
|
66c2291312 | ||
|
|
e5fa1b871f |
No files matched your search
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
/** 扩展名大写;没有扩展名或扩展名不像扩展名时回「文件」(不猜类型)。 */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.domain.parse.parse
|
||||
|
||||
/*
|
||||
* MessageEditText —— 「一条消息在编辑框里长什么样」的纯逻辑(无 Android / Compose 依赖)。
|
||||
*
|
||||
* 口径:编辑框只显示**纯文本**,但绝不静默丢东西。两件事必须同时成立:
|
||||
* 1. 原文本来就是纯文本 ⇒ 显示与保存都**逐字节恒等**(`hasMarkup == false`);
|
||||
* 2. 原文真的带标记 ⇒ 显示剥掉标记的可见文本,并由弹层**明说**保存会把那些标记换成用户写的这段字
|
||||
* (`hasMarkup == true`,见 `ui/chat/MessageEditDialog.kt`)。
|
||||
* 所以「剥标记」这条路只对**真有标记**的内容生效,对今天真实的主会话数据(user 消息全是纯文本)
|
||||
* 是一条恒等变换——它是防御,不是修一个正在发生的 bug。这也正是不能顺手把纯文本 trim /
|
||||
* 折叠空行 / 统一换行符的原因:那会把恒等变成「悄悄地改」。
|
||||
*
|
||||
* 剥标记**只用** `domain/parse` 这一个解析器([parse]),不另写正则:两套口径必然分叉,
|
||||
* 屏幕上读到的与她实际改的就会是两段不同的文本。拼纯文本的做法与 `ui/chat/SessionSearchModel.kt`
|
||||
* 的 `plainSearchText` 同源(同一份 `parse` 结果、同一句「按片段顺序取文本」),
|
||||
* 只是彼此的用途不同:搜索片段折成一行、这里保持段落,所以两者的拼接方式各自写在自己的文件里。
|
||||
*
|
||||
* 为什么保存时**不**把标记装回去:任何回填都要先猜「旁白/动作该插回哪一句之间」,
|
||||
* 而这不是本端有权替用户决定的事——猜错就是静默移动她说过的话的位置,且用户看不出发生了什么。
|
||||
* 提交的就是编辑框里那串纯文本本身,标记的归宿由弹层那一行小字**提前说清**。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 一条消息在编辑框里的样子(纯数据)。
|
||||
*
|
||||
* 两个字段是一体的:`text` 是用户看得到、也真的要动手改的那串字,`hasMarkup` 说的是
|
||||
* 「保存会不会因此丢掉别的东西」——弹层靠它决定要不要讲那一句话。
|
||||
*/
|
||||
data class EditableMessage(
|
||||
/** 编辑框显示、也就是用户实际编辑的**纯文本**。 */
|
||||
val text: String,
|
||||
/**
|
||||
* 原文里**确实带标记**(`<p>` / `<action>` / `<narration>`)⇒ 保存会把那些标记换成 `text`。
|
||||
* false = 原文就是纯文本,显示与保存都是**恒等**的。
|
||||
*/
|
||||
val hasMarkup: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* 已知标签的容差,与 [parse] 逐字对齐(只认解析器真正认识的那几个标签):
|
||||
* `<p>`(可带属性)/ `</p>` / `<action>` / `</action>` / `<narration>` / `</narration>`。
|
||||
*
|
||||
* 绝不能「见到一个 `<` 就当标记」:用户打的 `1 < 2`、`<div>`、`<3`、`a<b` 都是普通文本,
|
||||
* 判成标记就会顺手把它们当标签吃掉——那才是真正的静默丢东西。大小写同样照解析器来:
|
||||
* `parse` 只认小写的 `<p>` / `<action>` / `<narration>`,所以 `<P>` 在这里也是普通文本。
|
||||
*/
|
||||
private val KNOWN_TAG_RE = Regex(
|
||||
"<p(\\s[^>]*)?>|</p>|<\\s*(action|narration)\\s*>|</\\s*(action|narration)\\s*>",
|
||||
)
|
||||
|
||||
/** 气泡之间的分隔:一个空行——屏幕上读到的两段之间就是这么空着的。 */
|
||||
private const val BUBBLE_SEPARATOR = "\n\n"
|
||||
|
||||
/**
|
||||
* 算出这条消息在编辑框里的样子(纯函数,无副作用)。
|
||||
*
|
||||
* 三分支,覆盖「标记只在真的有标记时才动手」这条红线:
|
||||
* - **原文不含任何已知标签** ⇒ 原样返回,且**一个字符都不动**(不 trim、不折叠连续空行、不换换行符);
|
||||
* - **含标签且解析出了气泡** ⇒ `text` = 可见纯文本(见下);
|
||||
* - **含标签但一个气泡都解析不出**(例如整条就是一个没闭合的畸形标签)⇒ `text` 回落成原文:
|
||||
* 宁可原样留着,也不赌一把去删掉屏幕上说不清是什么的东西。
|
||||
*
|
||||
* `hasMarkup` 只在「解析**真的**换掉了什么」时为 true,也就是 `text != content`:
|
||||
* 它承诺的是「保存后这条消息和你现在看到的不一样」,而这个承诺必须句句属实——
|
||||
* 畸形标签、`<p> </p>` 这类解析器按普通文本处理的输入走的是恒等路径,
|
||||
* 那就该如实说「什么都没被替换」,而不是先吓用户一下。
|
||||
*/
|
||||
fun editableMessageOf(content: String): EditableMessage {
|
||||
if (!KNOWN_TAG_RE.containsMatchIn(content)) return EditableMessage(content, hasMarkup = false)
|
||||
val text = visibleTextOf(content)
|
||||
return EditableMessage(text, hasMarkup = text != content)
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记 → 可见纯文本:每个气泡按**原始片段顺序**取文本(正文与旁白/动作一视同仁,都不丢),
|
||||
* 气泡之间一个空行,最后整体 trim。
|
||||
*
|
||||
* 按 `parts` 顺序拼而不是按 head/body/tail 拼:`parts` 就是原文顺序,[parse] 的 head 只是
|
||||
* 「该把这块画在气泡上方」的渲染提示,用它重排会改掉用户读到的次序。
|
||||
*
|
||||
* 每个气泡**自己也 trim**(然后才是气泡之间的空行):气泡在屏幕上本就是独立的一块,
|
||||
* 块内的首尾空白渲染上不显示;不 trim 的话 `</p>\n\n<p>` 这种 Web 形态的块间空行会与
|
||||
* 分隔符叠加成四个换行,读起来就不是屏幕上那两段了。这里去掉的只可能是空白,
|
||||
* 一个可见字符都不会掉。
|
||||
*
|
||||
* 一个气泡都解析不出时**原样返回 `content`**:那是「宁可不剥」的落点,不是错误。
|
||||
*/
|
||||
private fun visibleTextOf(content: String): String {
|
||||
val bubbles = parse(content)
|
||||
if (bubbles.isEmpty()) return content
|
||||
return bubbles
|
||||
.joinToString(BUBBLE_SEPARATOR) { bubble -> bubble.parts.joinToString("") { it.text }.trim() }
|
||||
.trim()
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import top.yeij.cyrene.domain.chat.ImageDelivery
|
||||
import top.yeij.cyrene.domain.chat.MessageFingerprint
|
||||
import top.yeij.cyrene.domain.chat.RetryKeyMemory
|
||||
import top.yeij.cyrene.domain.chat.SendChannel
|
||||
import top.yeij.cyrene.domain.chat.editableMessageOf
|
||||
import top.yeij.cyrene.domain.chat.readBounded
|
||||
import top.yeij.cyrene.domain.chat.toDocumentFailure
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
@@ -356,12 +357,18 @@ sealed interface MessageActionState {
|
||||
val error: String?
|
||||
|
||||
/**
|
||||
* 编辑弹层。[draft] 初值是**服务端存的原始 `content`**(含 `<p>` / `<action>` 这类标记)——
|
||||
* 编辑的就是那个字符串本身,见 [MessageEditDialog] 的说明。
|
||||
* 编辑弹层。[draft] 是**纯文本**:[editableMessageOf] 把服务端存的那串 `content` 里的
|
||||
* `<p>` / `<action>` / `<narration>` 剥掉,剩下的就是用户看得到、也真的要动手改的字
|
||||
* (原文本来就是纯文本时**逐字节不变**)。
|
||||
*
|
||||
* [markupReplaced] 把这件事的**后果**一并带给弹层:原文真带标记时,保存就是拿用户写的这段字
|
||||
* 换掉那些标记,弹层必须据此**明说**——见 [MessageEditDialog] 的说明。
|
||||
*/
|
||||
data class Edit(
|
||||
override val messageId: String,
|
||||
val draft: String,
|
||||
/** 原文带标记 ⇒ 保存会把那些标记换成用户写的纯文本(弹层据此**明说**这件事)。 */
|
||||
val markupReplaced: Boolean = false,
|
||||
override val inFlight: Boolean = false,
|
||||
override val error: String? = null,
|
||||
) : MessageActionState
|
||||
@@ -675,13 +682,23 @@ class ChatViewModel(
|
||||
* 打开编辑弹层。**只对 `user` 消息开放**(口径见 [canEditMessage],UI 隐藏入口 + 这里再挡一次,
|
||||
* 判据是同一份纯函数)。
|
||||
*
|
||||
* 初值是**服务端存的原始 `content`**(可能带 `<p>` / `<action>` 这类标记)——那是唯一真源,
|
||||
* 编辑它才是「改这条消息」;先剥标记再回写会静默丢掉旁白/动作。
|
||||
* 初值走 [editableMessageOf]:编辑框里是**纯文本**(剥掉 `<p>` / `<action>` / `<narration>`),
|
||||
* 原文本来就是纯文本时逐字节不变。原文真带标记的话,那件事**不能不说**——所以
|
||||
* [MessageActionState.Edit.markupReplaced] 一起带上,由弹层明说「保存后只剩你写的这段字」。
|
||||
*
|
||||
* 只算一次:`_state.update` 的 lambda 可能被重跑(CAS 重试),别把纯函数调用塞进它里面。
|
||||
*/
|
||||
fun startEdit(message: HistoryMessageDto) {
|
||||
if (!canEditMessage(message)) return
|
||||
val editable = editableMessageOf(message.content)
|
||||
_state.update {
|
||||
it.copy(messageAction = MessageActionState.Edit(message.id, draft = message.content))
|
||||
it.copy(
|
||||
messageAction = MessageActionState.Edit(
|
||||
messageId = message.id,
|
||||
draft = editable.text,
|
||||
markupReplaced = editable.hasMarkup,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -709,6 +726,11 @@ class ChatViewModel(
|
||||
* 提交编辑:先校验([validateMessageEdit],空 / 超 4000 rune 的服务端必拒,本端不发这一趟),
|
||||
* 再 `PATCH /api/v1/messages/{id}`。
|
||||
*
|
||||
* **发出去的就是 `action.draft` 本身**(弹层里那串纯文本,原样、不 trim)。
|
||||
* **不把剥掉的标记装回去**:任何回填都要先猜「旁白/动作该插回哪一句之间」,猜错就是静默移动
|
||||
* 她说过的话,而用户看不出发生了什么——这个猜测本端没有资格替用户做。标记的归宿由弹层在提交前
|
||||
* **明说**([MessageActionState.Edit.markupReplaced]),不是提交后才发现。
|
||||
*
|
||||
* 成功与失败都不在这里直接改消息列表:成功走 [applyMessageEdited](与广播同一条出口),
|
||||
* 失败走 [applyMessageActionFailure](弹层还开着就留在弹层里,用户改完能直接重试)。
|
||||
*/
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -84,11 +84,19 @@ fun MessageActionMenu(
|
||||
/**
|
||||
* 编辑弹层:一个多行输入框 + 码点计数 + 取消 / 保存。
|
||||
*
|
||||
* ## 编辑的是「服务端存的那个字符串」
|
||||
* 初值就是消息的原始 `content`。后端的正文里可能有 Web 渲染链路的标记(`<p>` / `<action>` /
|
||||
* `<narration>`),它们在气泡里被 `domain/parse` 解析成正文与旁白——但**存储里就是一个字符串**,
|
||||
* 编辑它才是「改这条消息」。先剥标记再回写会静默丢掉旁白与动作(而且丢掉的正是用户没打算删的部分)。
|
||||
* 代价是编辑框里会看到标记本身;这是刻意的取舍,不在这里做「只显示纯文本」的假象。
|
||||
* ## 编辑框里是纯文本,但绝不静默丢东西
|
||||
* 初值是 `domain/chat/MessageEditText.kt` 的 `editableMessageOf` 算出来的**纯文本**:后端的正文里
|
||||
* 可能有 Web 渲染链路的标记(`<p>` / `<action>` / `<narration>`),它们在气泡里被 `domain/parse`
|
||||
* 解析成正文与旁白——让用户对着尖括号改字不是「改这条消息」该有的样子。
|
||||
* 原文本来就是纯文本时**逐字节不变**(不 trim、不折叠空行),所以「剥标记」这条路径只在真有标记时才生效。
|
||||
*
|
||||
* 剥标记是有代价的:保存回去的是这段纯文本,原来的旁白/动作就没了。所以这件事**必须明说**——
|
||||
* [MessageActionState.Edit.markupReplaced] 为 true 时,输入框下面会多一行小字,把「保存后这条消息
|
||||
* 只剩你写的这段文字、原来的旁白与动作会被替换掉」讲清楚;为 false(原文没有标记)时**一行都不多显示**。
|
||||
*
|
||||
* 反过来,保存时也**不**把标记猜着装回去:任何回填都要先猜「旁白/动作该插回哪一句之间」,
|
||||
* 猜错就是静默移动她说过的话,且用户看不出发生了什么。提交的就是用户写的这段字本身
|
||||
* ([ChatViewModel.submitEdit]),不猜是刻意的。
|
||||
*
|
||||
* ## 计数为什么按码点
|
||||
* 服务端上限是 4000 **rune**([MESSAGE_EDIT_MAX_RUNES]),所以这里也用 [runeCount]:
|
||||
@@ -99,7 +107,8 @@ fun MessageActionMenu(
|
||||
* [MessageActionState.Edit.error] 非空时显示在按钮上方:这条消息原地重试即可,输入不丢。
|
||||
* (弹层被关掉之后才失败的,落到页面顶部那行横幅,见 [ChatUiState.applyMessageActionFailure]。)
|
||||
*
|
||||
* @param state 弹层的草稿 / 进行态 / 失败原因(状态在 VM 里,弹层自己不持有提交结果)。
|
||||
* @param state 弹层的草稿(纯文本)/ 原文带不带标记 / 进行态 / 失败原因(状态在 VM 里,
|
||||
* 弹层自己不持有提交结果)。
|
||||
*/
|
||||
@Composable
|
||||
fun MessageEditDialog(
|
||||
@@ -140,6 +149,15 @@ fun MessageEditDialog(
|
||||
MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
},
|
||||
)
|
||||
if (state.markupReplaced) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// 明说「保存会替换掉什么」:原文的旁白/动作标记不会再出现在这条消息里。
|
||||
Text(
|
||||
text = "原文带旁白/动作标记:保存后这条消息只剩你写的这段文字,原来的旁白与动作会被替换掉。",
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
if (state.error != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* 编辑框初值:什么时候逐字节恒等,什么时候剥标记,剥完还剩下什么。
|
||||
*
|
||||
* 红线是「不许静默丢东西」,所以两个方向都要钉住:
|
||||
* - **纯文本一个字符都不许动**——哪怕带连续空行、CRLF、全角空格,哪怕里面有 `<`(`1 < 2`、`<div>`
|
||||
* 都是普通文本,见到尖括号就剥是把用户打的字当标签吃掉);
|
||||
* - **真有标记时也不许把旁白/动作丢掉**——旁白是她说的话的一部分,剥掉标签不等于可以丢内容。
|
||||
*
|
||||
* 判据本身在 [editableMessageOf],剥标记完全交给 `domain/parse`(项目里唯一的解析器),
|
||||
* 这里不重复实现它的规则。
|
||||
*/
|
||||
class MessageEditTextTest {
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 一、纯文本:逐字节恒等
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** 纯文本路径的唯一断言:`hasMarkup` 为 false,且文本与原文**逐字符**相等。 */
|
||||
private fun assertUntouched(content: String) {
|
||||
val editable = editableMessageOf(content)
|
||||
assertFalse("「$content」不该被判成带标记", editable.hasMarkup)
|
||||
assertEquals("「$content」必须逐字符原样", content, editable.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `普通中英文纯文本逐字节恒等`() {
|
||||
assertUntouched("你好呀,今天过得怎么样?Hello, world — 我在这儿。")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `空串就是空串`() {
|
||||
assertEquals(EditableMessage(text = "", hasMarkup = false), editableMessageOf(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `首尾空白与各种空白字符原样保留`() {
|
||||
// 用户打的就是这些字:不 trim,也不把全角空格 / 不换行空格 / 零宽空格 / 制表符换成别的。
|
||||
assertUntouched("\t 第一行\u3000\u00A0\u200B\n 第二行 \n")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `连续三个换行不折叠`() {
|
||||
// 解析器的空行切分是**渲染**的规则,编辑框不欠它什么:多打的空行照旧是用户的东西。
|
||||
assertUntouched("第一段\n\n\n\n第二段")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `换行符不统一 —— CRLF 与单独的 CR 都原样保留`() {
|
||||
assertUntouched("第一行\r\n第二行\r第三行\n")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `小于号不是标记 —— 数学符号与普通标签都算普通文本`() {
|
||||
// 判据只认解析器真正认识的那三个标签。见到 `<` 就当标记,就会把用户打的数学符号吃掉。
|
||||
listOf(
|
||||
"1 < 2 且 3 > 2",
|
||||
"if (a < b) { c }",
|
||||
"a<b>c",
|
||||
"<3",
|
||||
"<div>这是一段 HTML</div>",
|
||||
"</span>",
|
||||
"<p2>这不关 p 标签的事</p2>",
|
||||
"<P>大小写不认(解析器只认小写 p)</P>",
|
||||
).forEach(::assertUntouched)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `裸的星号旁白不触发剥标记`() {
|
||||
// 星号式旁白(*…* / *动作:…*)不是本模块认的标记:编辑框原样显示,保存也原样发。
|
||||
assertUntouched("*弯起眼睛笑了*我今天想你了")
|
||||
assertUntouched("*动作:她歪了歪头*")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 二、真有标记:剥掉标签,内容一件不少
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `线上真实形状 —— 旁白与正文都在,且不再有尖括号`() {
|
||||
val content = "<p><action>把整卷纸条从桌头铺到桌尾,最后终于摸到了尾巴</action>" +
|
||||
"读到头也读到尾啦,亲爱的♪</p>\n\n<p>完整长度 624046 字符。</p>"
|
||||
|
||||
val editable = editableMessageOf(content)
|
||||
|
||||
assertTrue(editable.hasMarkup)
|
||||
assertEquals(
|
||||
"把整卷纸条从桌头铺到桌尾,最后终于摸到了尾巴读到头也读到尾啦,亲爱的♪\n\n完整长度 624046 字符。",
|
||||
editable.text,
|
||||
)
|
||||
assertFalse("剥完不该再剩标签", editable.text.contains('<') || editable.text.contains('>'))
|
||||
// 旁白(动作)与正文都得在:旁白是她说的话的一部分,不是可以顺手丢掉的东西。
|
||||
assertTrue(editable.text.contains("把整卷纸条从桌头铺到桌尾,最后终于摸到了尾巴"))
|
||||
assertTrue(editable.text.contains("读到头也读到尾啦,亲爱的♪"))
|
||||
assertTrue(editable.text.contains("完整长度 624046 字符。"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `纯旁白消息 —— 旁白那句话本身留下`() {
|
||||
val editable = editableMessageOf("<action>弯起眼睛笑了</action>")
|
||||
assertTrue(editable.hasMarkup)
|
||||
assertEquals("弯起眼睛笑了", editable.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `narration 标签同样算标记`() {
|
||||
val editable = editableMessageOf("<narration>内心:她也在想你</narration>")
|
||||
assertTrue(editable.hasMarkup)
|
||||
assertEquals("内心:她也在想你", editable.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `两个气泡之间只空一行`() {
|
||||
assertEquals("第一段\n\n第二段", editableMessageOf("<p>第一段</p>\n\n<p>第二段</p>").text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `带属性的 p 标签也认 —— 容差与解析器一致`() {
|
||||
val editable = editableMessageOf("<p class=\"line\">你好呀</p>")
|
||||
assertTrue(editable.hasMarkup)
|
||||
assertEquals("你好呀", editable.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `气泡内的换行保留`() {
|
||||
assertEquals("第一行\n第二行", editableMessageOf("<p>第一行\n第二行</p>").text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `气泡内的 emoji 不被拆坏`() {
|
||||
assertEquals("你好🎀再见", editableMessageOf("<p>你好🎀再见</p>").text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `head 旁白排在正文之前 —— 按原文顺序,不按渲染分块重排`() {
|
||||
val editable = editableMessageOf("<p><action>她歪了歪头</action>你好呀</p>")
|
||||
assertTrue(editable.hasMarkup)
|
||||
assertEquals("她歪了歪头你好呀", editable.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tail 旁白也留下`() {
|
||||
assertEquals("你好呀她挥了挥手", editableMessageOf("<p>你好呀<action>她挥了挥手</action></p>").text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `标签外散落的正文不丢`() {
|
||||
assertEquals(
|
||||
"开头这几句没包在标签里\n\n第二段",
|
||||
editableMessageOf("开头这几句没包在标签里\n\n<p>第二段</p>").text,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `气泡内的星号旁白随标签一起剥掉 —— 口径来自解析器`() {
|
||||
// 有标记时怎么剥由 parse 说了算:它认 *笑* 是旁白,所以编辑框里留下旁白那句话、没有星号。
|
||||
assertEquals("hi 笑", editableMessageOf("<p>hi *笑*</p>").text)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 三、畸形:宁可不剥,也不许丢字
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `没闭合的 p 不抛异常 —— 解析器按普通文本处理,这里就恒等`() {
|
||||
assertUntouched("<p>没闭合就结束")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `没闭合的 action 同样原样留着`() {
|
||||
assertUntouched("<action>她笑了,可是没写结尾")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `只有一对空标签的消息恒等 —— 解析不出气泡就不剥`() {
|
||||
// parse("<p></p>") 是空列表:宁可把 `content` 原样留着,也不赌一把把整条消息删空。
|
||||
assertUntouched("<p></p>")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user