From 66c229131233c42aabe4a753716024b4286a2e7d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 22:07:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E7=BC=96=E8=BE=91=E6=A1=86?= =?UTF-8?q?=E5=8F=AA=E6=98=BE=E7=A4=BA=E7=BA=AF=E6=96=87=E6=9C=AC=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E7=9C=9F=E6=9C=89=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E6=97=B6=E6=89=8D=E5=89=A5=EF=BC=8C=E4=B8=94=E5=BC=B9=E5=B1=82?= =?UTF-8?q?=E6=98=8E=E8=AF=B4=E4=BC=9A=E6=9B=BF=E6=8D=A2=E6=8E=89=E4=BB=80?= =?UTF-8?q?=E4=B9=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人拍板:编辑单条消息时输入框里只放纯文本,不再让用户对着

/ / 改字。 红线是「不许静默丢东西」,所以这次不是「把标记一剥了事」: - 新增纯 Kotlin 模块 domain/chat/MessageEditText.kt(无 Android / Compose 依赖)。 原文不含已知标签时逐字节恒等:不 trim、不折叠连续空行、不换换行符;含标签时用 domain/parse(项目里唯一的解析器,不另写正则,免得两套口径分叉)按原始片段顺序拼出可见纯文本, 正文与旁白/动作一并保留,气泡之间空一行;一个气泡都解析不出就回落成原文(宁可不剥)。 hasMarkup 只在「保存真的会替换掉什么」时为 true。 - MessageActionState.Edit 新增 markupReplaced;startEdit 只算一次 editableMessageOf (不放进 state.update 的 lambda 里),draft 拿纯文本、markupReplaced 拿原文带不带标记。 - MessageEditDialog:markupReplaced 为 true 时在计数行下方多一行小字,写明「保存后这条消息只剩 你写的这段文字,原来的旁白与动作会被替换掉」;为 false 时一行都不多显示。计数与保存禁用逻辑不变。 - 保存提交的就是 draft 本身,不把标记猜着装回去:任何回填都要先猜「旁白该插回哪一句之间」, 猜错就是静默移动她说过的话,用户还看不出发生了什么(理由写在 submitEdit 与弹层的 KDoc 里)。 - 新增 MessageEditTextTest(21 例):纯文本恒等(空串、首尾空白、连续三个换行、CRLF、 以及 1 < 2 /

/

/ 这类「有尖括号但不是标记」的普通文本)、线上真实标记串、 纯旁白、带属性的 p 标签、head/tail 旁白的先后顺序、气泡间只空一行、畸形未闭合不丢字。 为什么这仍然值得做:主会话 64 条 user 消息全是纯文本,所以对今天的真实数据这是一条恒等变换—— 它是防御性口径,不是修一个正在发生的 bug;也正因如此,纯文本路径必须逐字节不变。 --- .../cyrene/domain/chat/MessageEditText.kt | 97 +++++++++ .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 32 ++- .../yeij/cyrene/ui/chat/MessageEditDialog.kt | 30 ++- .../cyrene/domain/chat/MessageEditTextTest.kt | 185 ++++++++++++++++++ 4 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageEditText.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/MessageEditTextTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageEditText.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageEditText.kt new file mode 100644 index 0000000..e65a8c0 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageEditText.kt @@ -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, + /** + * 原文里**确实带标记**(`

` / `` / ``)⇒ 保存会把那些标记换成 `text`。 + * false = 原文就是纯文本,显示与保存都是**恒等**的。 + */ + val hasMarkup: Boolean, +) + +/** + * 已知标签的容差,与 [parse] 逐字对齐(只认解析器真正认识的那几个标签): + * `

`(可带属性)/ `

` / `` / `` / `` / ``。 + * + * 绝不能「见到一个 `<` 就当标记」:用户打的 `1 < 2`、`
`、`<3`、`a` / `` / ``,所以 `

` 在这里也是普通文本。 + */ +private val KNOWN_TAG_RE = Regex( + "]*)?>|

|<\\s*(action|narration)\\s*>|", +) + +/** 气泡之间的分隔:一个空行——屏幕上读到的两段之间就是这么空着的。 */ +private const val BUBBLE_SEPARATOR = "\n\n" + +/** + * 算出这条消息在编辑框里的样子(纯函数,无副作用)。 + * + * 三分支,覆盖「标记只在真的有标记时才动手」这条红线: + * - **原文不含任何已知标签** ⇒ 原样返回,且**一个字符都不动**(不 trim、不折叠连续空行、不换换行符); + * - **含标签且解析出了气泡** ⇒ `text` = 可见纯文本(见下); + * - **含标签但一个气泡都解析不出**(例如整条就是一个没闭合的畸形标签)⇒ `text` 回落成原文: + * 宁可原样留着,也不赌一把去删掉屏幕上说不清是什么的东西。 + * + * `hasMarkup` 只在「解析**真的**换掉了什么」时为 true,也就是 `text != content`: + * 它承诺的是「保存后这条消息和你现在看到的不一样」,而这个承诺必须句句属实—— + * 畸形标签、`

` 这类解析器按普通文本处理的输入走的是恒等路径, + * 那就该如实说「什么都没被替换」,而不是先吓用户一下。 + */ +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 的话 `

\n\n

` 这种 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() +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 6a6dd84..8cac68e 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -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`**(含 `

` / `` 这类标记)—— - * 编辑的就是那个字符串本身,见 [MessageEditDialog] 的说明。 + * 编辑弹层。[draft] 是**纯文本**:[editableMessageOf] 把服务端存的那串 `content` 里的 + * `

` / `` / `` 剥掉,剩下的就是用户看得到、也真的要动手改的字 + * (原文本来就是纯文本时**逐字节不变**)。 + * + * [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`**(可能带 `

` / `` 这类标记)——那是唯一真源, - * 编辑它才是「改这条消息」;先剥标记再回写会静默丢掉旁白/动作。 + * 初值走 [editableMessageOf]:编辑框里是**纯文本**(剥掉 `

` / `` / ``), + * 原文本来就是纯文本时逐字节不变。原文真带标记的话,那件事**不能不说**——所以 + * [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](弹层还开着就留在弹层里,用户改完能直接重试)。 */ diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageEditDialog.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageEditDialog.kt index d4fd502..7ff0331 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageEditDialog.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageEditDialog.kt @@ -84,11 +84,19 @@ fun MessageActionMenu( /** * 编辑弹层:一个多行输入框 + 码点计数 + 取消 / 保存。 * - * ## 编辑的是「服务端存的那个字符串」 - * 初值就是消息的原始 `content`。后端的正文里可能有 Web 渲染链路的标记(`

` / `` / - * ``),它们在气泡里被 `domain/parse` 解析成正文与旁白——但**存储里就是一个字符串**, - * 编辑它才是「改这条消息」。先剥标记再回写会静默丢掉旁白与动作(而且丢掉的正是用户没打算删的部分)。 - * 代价是编辑框里会看到标记本身;这是刻意的取舍,不在这里做「只显示纯文本」的假象。 + * ## 编辑框里是纯文本,但绝不静默丢东西 + * 初值是 `domain/chat/MessageEditText.kt` 的 `editableMessageOf` 算出来的**纯文本**:后端的正文里 + * 可能有 Web 渲染链路的标记(`

` / `` / ``),它们在气泡里被 `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( diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/MessageEditTextTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/MessageEditTextTest.kt new file mode 100644 index 0000000..7676e57 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/MessageEditTextTest.kt @@ -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`、`

` + * 都是普通文本,见到尖括号就剥是把用户打的字当标签吃掉); + * - **真有标记时也不许把旁白/动作丢掉**——旁白是她说的话的一部分,剥掉标签不等于可以丢内容。 + * + * 判据本身在 [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 }", + "ac", + "<3", + "
这是一段 HTML
", + "", + "这不关 p 标签的事", + "

大小写不认(解析器只认小写 p)

", + ).forEach(::assertUntouched) + } + + @Test + fun `裸的星号旁白不触发剥标记`() { + // 星号式旁白(*…* / *动作:…*)不是本模块认的标记:编辑框原样显示,保存也原样发。 + assertUntouched("*弯起眼睛笑了*我今天想你了") + assertUntouched("*动作:她歪了歪头*") + } + + // ------------------------------------------------------------------ + // 二、真有标记:剥掉标签,内容一件不少 + // ------------------------------------------------------------------ + + @Test + fun `线上真实形状 —— 旁白与正文都在,且不再有尖括号`() { + val content = "

把整卷纸条从桌头铺到桌尾,最后终于摸到了尾巴" + + "读到头也读到尾啦,亲爱的♪

\n\n

完整长度 624046 字符。

" + + 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("弯起眼睛笑了") + assertTrue(editable.hasMarkup) + assertEquals("弯起眼睛笑了", editable.text) + } + + @Test + fun `narration 标签同样算标记`() { + val editable = editableMessageOf("内心:她也在想你") + assertTrue(editable.hasMarkup) + assertEquals("内心:她也在想你", editable.text) + } + + @Test + fun `两个气泡之间只空一行`() { + assertEquals("第一段\n\n第二段", editableMessageOf("

第一段

\n\n

第二段

").text) + } + + @Test + fun `带属性的 p 标签也认 —— 容差与解析器一致`() { + val editable = editableMessageOf("

你好呀

") + assertTrue(editable.hasMarkup) + assertEquals("你好呀", editable.text) + } + + @Test + fun `气泡内的换行保留`() { + assertEquals("第一行\n第二行", editableMessageOf("

第一行\n第二行

").text) + } + + @Test + fun `气泡内的 emoji 不被拆坏`() { + assertEquals("你好🎀再见", editableMessageOf("

你好🎀再见

").text) + } + + @Test + fun `head 旁白排在正文之前 —— 按原文顺序,不按渲染分块重排`() { + val editable = editableMessageOf("

她歪了歪头你好呀

") + assertTrue(editable.hasMarkup) + assertEquals("她歪了歪头你好呀", editable.text) + } + + @Test + fun `tail 旁白也留下`() { + assertEquals("你好呀她挥了挥手", editableMessageOf("

你好呀她挥了挥手

").text) + } + + @Test + fun `标签外散落的正文不丢`() { + assertEquals( + "开头这几句没包在标签里\n\n第二段", + editableMessageOf("开头这几句没包在标签里\n\n

第二段

").text, + ) + } + + @Test + fun `气泡内的星号旁白随标签一起剥掉 —— 口径来自解析器`() { + // 有标记时怎么剥由 parse 说了算:它认 *笑* 是旁白,所以编辑框里留下旁白那句话、没有星号。 + assertEquals("hi 笑", editableMessageOf("

hi *笑*

").text) + } + + // ------------------------------------------------------------------ + // 三、畸形:宁可不剥,也不许丢字 + // ------------------------------------------------------------------ + + @Test + fun `没闭合的 p 不抛异常 —— 解析器按普通文本处理,这里就恒等`() { + assertUntouched("

没闭合就结束") + } + + @Test + fun `没闭合的 action 同样原样留着`() { + assertUntouched("她笑了,可是没写结尾") + } + + @Test + fun `只有一对空标签的消息恒等 —— 解析不出气泡就不剥`() { + // parse("

") 是空列表:宁可把 `content` 原样留着,也不赌一把把整条消息删空。 + assertUntouched("

") + } +}