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:
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>")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user