feat(chat): 编辑框只显示纯文本 —— 真有标记时才剥,且弹层明说会替换掉什么

主人拍板:编辑单条消息时输入框里只放纯文本,不再让用户对着 <p> / <action> / <narration> 改字。
红线是「不许静默丢东西」,所以这次不是「把标记一剥了事」:

- 新增纯 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 / <div> / <P> / <p2> 这类「有尖括号但不是标记」的普通文本)、线上真实标记串、
  纯旁白、带属性的 p 标签、head/tail 旁白的先后顺序、气泡间只空一行、畸形未闭合不丢字。

为什么这仍然值得做:主会话 64 条 user 消息全是纯文本,所以对今天的真实数据这是一条恒等变换——
它是防御性口径,不是修一个正在发生的 bug;也正因如此,纯文本路径必须逐字节不变。
This commit is contained in:
AskaEth committed 2026-10-04 22:07:40 +08:00
1 parent e4915a6904
commit 66c2291312
4 files changed
+333 -11

No files matched your search

@@ -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>")
}
}