fix(parse): 容错落单的段落标签,绝不把 <p> 当正文渲染
真机实测:后端产出的某条消息第三段是 `<p><p>那现在…?</p>`(两个开标签、一个闭标签)。 P_BLOCK_RE 是非贪婪的,先把外层 `<p>` 配到那个 `</p>`,内层那个裸 `<p>` 就成了「块内容」; 而 appendAnnotations 只认 `<action>` / `<narration>` / 星号,不认裸 `<p>`,于是它作为普通文本 落进气泡正文,屏幕上直接显示 `<p>那现在…`。 改了什么:新增 stripLooseTags,在切出 `<p>` 块之后按区剥掉「落单标签」—— - 消息里已有完整 `<p>…</p>` 块时,块内块外多出来的每个 `<p>` / `</p>` 一律剥掉,块外没被 ROLE_RE 认领的 `<action>` / `</action>` 同样剥掉; - 一个完整块都没有时,维持既有「未闭合 `<p>` 整段回落字面文本」的契约(编辑框靠它保证不 静默改字),只再剥两类连「被截断的结构」都算不上的东西:落单的 `</p>` 与后面什么都没有的空开标签。 判据只认完整匹配已知标签词表的形状,`1 < 2` / `<3` / `a<b` / `<p2>` / `<P>` / `<p`(残缺) 一律原样保留。 为什么选择「剥」而不是「原样显示」:这些是服务端产出的标记、不是她说的话;渲染成正文等于往 气泡里塞一串用户既看不懂、也改不掉的字符,而剥掉只是少显示几个本就不该出现的字符,正文一个字 都不动。这只是客户端的容错,不代表服务端这样发是对的。 流式:stablePrefixEnd 不动。扣留点取「第一个没闭合的开标签」,而前缀里的每个开标签必定在同一个 前缀内配到闭标签,所以「是不是落单」在它被产出的那一刻就已定死,中间态剥掉、收口也不用改口; 放宽扣留(把重复的 `<p><p>` 当外层冗余)反而会让 `<p>甲<p>乙` 在中间态把 `<p>甲` 当字面文本吐上屏, 等 `</p>` 到了再跳成 `甲乙`。新增测试钉住这条:逐字符喂实测畸形串时,任何一帧都不许漏出标签, 且已显示的正文只许增不许改口。 测试:assembleDebug 通过;ChatContentParserTest 55 项全绿(新增 17 项);全量 110 suites / 1349 tests / 0 failures / 0 errors(主仓同 HEAD 基线 110 suites / 1332 tests,本次 +17 项)。
This commit is contained in:
1 parent
e5e61e131d
commit
464e91621e
3 files changed
+287
-7
No files matched your search
@@ -245,6 +245,109 @@ class ChatContentParserTest {
|
||||
assertTrue(bubbles[0].parts.any { it is ContentPart.Action })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 落单标签容错:宁可剥掉标签,也绝不把标签当正文
|
||||
//
|
||||
// 畸形 markup 来自服务端(客户端只做容错,不代表服务端这样发是对的)。
|
||||
// 这些断言是上面「畸形 / 未闭合」那组的另一面,两边必须同时成立。
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** 线上实测的一条畸形消息:第三段多一个 `<p>`,只有一个 `</p>`。 */
|
||||
private val malformedRealMessage =
|
||||
"<p><action>满意地点点头</action></p>\n" +
|
||||
"<p>真乖♪ 这可是今天最让人家放心的一句。</p>\n" +
|
||||
"<p><p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>"
|
||||
|
||||
@Test
|
||||
fun realMalformedMessageRendersCleanBubbles() {
|
||||
val bubbles = parse(malformedRealMessage)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"真乖♪ 这可是今天最让人家放心的一句。",
|
||||
"那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?",
|
||||
),
|
||||
bubbles.map { it.body },
|
||||
)
|
||||
// 纯旁白块照既有规则依附到下一个有正文的气泡上(head)。
|
||||
assertEquals(listOf(ContentPart.Action("满意地点点头")), bubbles[0].head)
|
||||
assertNoLooseTagInRenderedText(bubbles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatedOpeningTagIsDropped() {
|
||||
assertEquals(listOf("正文"), parse("<p><p>正文</p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strayClosingTagIsDropped() {
|
||||
assertEquals(listOf("正文"), parse("<p>正文</p></p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatedOpeningTagInLaterBlockKeepsBubbleSplit() {
|
||||
assertEquals(listOf("甲", "乙"), parse("<p>甲</p><p><p>乙</p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatedOpeningTagKeepsTheNoteAttachedToTheNextParagraph() {
|
||||
val bubble = parse("<p><action>点头</action></p><p><p>乙</p>").single()
|
||||
assertEquals("乙", bubble.body)
|
||||
assertEquals(listOf(ContentPart.Action("点头")), bubble.head)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatedOpeningTagWithAttributesIsDropped() {
|
||||
assertEquals(listOf("正文"), parse("<p><p class=\"line\">正文</p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun looseTagsSurroundedByWhitespaceAreDropped() {
|
||||
assertEquals(listOf("正文"), parse(" <p>\n<p>正文</p>\n</p> ").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leadingAndTrailingLooseTagsLeaveNoEmptyBubble() {
|
||||
assertEquals(listOf("甲"), parse("</p><p>甲</p></p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loneParagraphTagsProduceNoBubble() {
|
||||
assertTrue(parse("<p>").isEmpty())
|
||||
assertTrue(parse("</p>").isEmpty())
|
||||
assertTrue(parse(" <p> ").isEmpty())
|
||||
assertTrue(parse("<p></p></p>").isEmpty())
|
||||
assertTrue(parse("<p><p>").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unclosedTagAfterACompleteBlockIsDroppedNotShown() {
|
||||
// 段落结构已被前一个完整块定死,尾部这个没闭合的 `<p>` 只可能是标记。
|
||||
assertEquals(listOf("甲", "乙"), parse("<p>甲</p><p>乙").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strayRoleTagOutsideParagraphIsDropped() {
|
||||
assertEquals(listOf("甲"), parse("<p>甲</p></action>").map { it.body })
|
||||
assertEquals(listOf("甲"), parse("</action><p>甲</p>").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainAngleBracketsAreNeverEaten() {
|
||||
assertEquals(listOf("1 < 2 且 3 > 2"), parse("1 < 2 且 3 > 2").map { it.body })
|
||||
assertEquals(listOf("<3 是想你的意思"), parse("<3 是想你的意思").map { it.body })
|
||||
assertEquals(listOf("a<b>c"), parse("a<b>c").map { it.body })
|
||||
assertEquals(listOf("<div>这是一段 HTML</div>"), parse("<div>这是一段 HTML</div>").map { it.body })
|
||||
assertEquals(listOf("<p2>这不关 p 标签的事</p2>"), parse("<p2>这不关 p 标签的事</p2>").map { it.body })
|
||||
assertEquals(listOf("<P>大小写不认</P>"), parse("<P>大小写不认</P>").map { it.body })
|
||||
assertEquals(listOf("你好<p"), parse("你好<p").map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainAngleBracketsInsideAParagraphAreNeverEaten() {
|
||||
assertEquals(listOf("甲 1 < 2", "乙"), parse("<p>甲 1 < 2</p><p>乙</p>").map { it.body })
|
||||
assertEquals(listOf("甲<p2>乙"), parse("<p>甲<p2>乙</p>").map { it.body })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 流式增量:最终结果必须与一次性解析一致
|
||||
// ------------------------------------------------------------------
|
||||
@@ -257,6 +360,7 @@ class ChatContentParserTest {
|
||||
<p>你好*弯起眼睛*</p>
|
||||
<p>这是*强调*文字</p>
|
||||
<p>*旁白:其实偷偷开心了一下*</p>
|
||||
<p><p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>
|
||||
<p>晚安。</p>
|
||||
""".trimIndent()
|
||||
|
||||
@@ -315,6 +419,63 @@ class ChatContentParserTest {
|
||||
assertEquals(listOf("第一", "第二"), second.map { it.body })
|
||||
}
|
||||
|
||||
/** 逐字符喂实测畸形消息:中间态任何一帧都不许把标签当正文吐上屏。 */
|
||||
@Test
|
||||
fun streamingRealMalformedMessageNeverLeaksTheTag() {
|
||||
val parser = StreamingContentParser()
|
||||
for (ch in malformedRealMessage) {
|
||||
assertNoLooseTagInRenderedText(parser.onDelta(ch.toString()))
|
||||
}
|
||||
assertEquals(parse(malformedRealMessage), parser.finish())
|
||||
}
|
||||
|
||||
/** 中间态只许「加」,不许改口:已经显示过的正文不能因为后续 delta 变样(那才是跳动)。 */
|
||||
@Test
|
||||
fun streamingRealMalformedMessageOnlyGrowsWhatItAlreadyShowed() {
|
||||
val parser = StreamingContentParser()
|
||||
var shown = emptyList<String>()
|
||||
for (ch in malformedRealMessage) {
|
||||
val bodies = parser.onDelta(ch.toString()).map { it.body }
|
||||
assertTrue("气泡数回退了:$shown → $bodies", bodies.size >= shown.size)
|
||||
shown.forEachIndexed { i, body ->
|
||||
assertTrue("已显示的正文改口了:$body → ${bodies[i]}", bodies[i].startsWith(body))
|
||||
}
|
||||
shown = bodies
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingRandomChunksOfMalformedMessageMatchOneShot() {
|
||||
val expected = parse(malformedRealMessage)
|
||||
val random = Random(20261006)
|
||||
repeat(25) {
|
||||
val parser = StreamingContentParser()
|
||||
var index = 0
|
||||
while (index < malformedRealMessage.length) {
|
||||
val size = 1 + random.nextInt(7)
|
||||
val end = minOf(index + size, malformedRealMessage.length)
|
||||
parser.onDelta(malformedRealMessage.substring(index, end))
|
||||
index = end
|
||||
}
|
||||
assertEquals(expected, parser.finish())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重复开标签必须连同它后面的正文一起被扣留到收口。
|
||||
*
|
||||
* 放宽扣留(把重复的 `<p>` 当「外层冗余」、让前缀继续推进)会把 `甲` 先吐上屏,
|
||||
* 等 `</p>` 到了再变成 `甲乙`——用户看到的字会跳,所以这里钉住「中间态一帧都不吐」。
|
||||
*/
|
||||
@Test
|
||||
fun streamingWithholdsDuplicateOpeningTagUntilItsPartnerArrives() {
|
||||
val parser = StreamingContentParser()
|
||||
for (chunk in listOf("<p>甲", "<p>乙", "</p>")) {
|
||||
assertNoLooseTagInRenderedText(parser.onDelta(chunk))
|
||||
}
|
||||
assertEquals(listOf("甲乙"), parser.finish().map { it.body })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingUnclosedActionFallsBackOnFinish() {
|
||||
val parser = StreamingContentParser()
|
||||
@@ -347,4 +508,13 @@ class ChatContentParserTest {
|
||||
assertTrue(parser.onDelta("").isEmpty())
|
||||
assertEquals(listOf("新"), parser.onDelta("<p>新</p>").map { it.body })
|
||||
}
|
||||
|
||||
/** 产出的任何片段里都不许出现段落标签本身——这是落单标签容错的底线。 */
|
||||
private fun assertNoLooseTagInRenderedText(bubbles: List<ParsedBubble>) {
|
||||
for (bubble in bubbles) {
|
||||
for (part in bubble.parts) {
|
||||
assertFalse("标签漏进了正文:${part.text}", part.text.contains("<p") || part.text.contains("</p"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user