package turnreply import ( "regexp" "strings" "testing" ) // 本文件锁定「未配对段落标签」的修复口径(paragraph_repair.go)。 // // 上报形状(安卓端 02-前后端协同事项.md):某条助手消息的 content **原始值**里,第三段 // 是**两个开标签一个闭标签** `

正文

`。客户端的解析契约 // `P_BLOCK_RE = ]*)?>([\s\S]*?)

` 是**非贪婪**的 ⇒ 它必然把 `

正文` 当成 // 气泡内容 ⇒ 用户直接看到字面的 `

`。任何按该契约实现的消费方(Web 面板 / IM 桥 / // 第三方)都会踩同一条,因此修在**产出侧**(落库/对外文本里不允许有未配对的段落标签)。 // // 两个断言助手分别对应两层: // - unpairedParagraphTag:标签配对本身(不出现落单的开或闭、不嵌套); // - clientBubblesWithLiteralTags:**按客户端契约**拆出的气泡正文里还残留字面 `

` // 标签 —— 它就是上报症状本身,且**不依赖**实现用的正则(独立口径)。 // reportedUnpairedReply 是上报原串(逐字照抄,`\n` 是真换行)。 const reportedUnpairedReply = "

满意地点点头

\n" + "

真乖♪ 这可是今天最让人家放心的一句。

\n" + "

那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?

" // reportedUnpairedFixed 是上报原串的**目标形态**:只有那处双开标签被合并( // `

正文

` ⇒ `

正文

`),其余一字不动。 const reportedUnpairedFixed = "

满意地点点头

\n" + "

真乖♪ 这可是今天最让人家放心的一句。

\n" + "

那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?

" // TestNormalizeParagraphs_ReportedUnpairedTagIsMerged 是必测①:上报原串规整后只剩 // 恰好一处改动 —— 第三段合并成一个 `

`。 func TestNormalizeParagraphs_ReportedUnpairedTagIsMerged(t *testing.T) { t.Parallel() got := NormalizeParagraphs(reportedUnpairedReply) if got != reportedUnpairedFixed { t.Fatalf("规整结果 =\n%q\nwant\n%q", got, reportedUnpairedFixed) } if strings.Count(got, "

") != 3 || strings.Count(got, "

") != 3 { t.Fatalf("段落标签数不配对:

=%d

=%d\n%q", strings.Count(got, "

"), strings.Count(got, "

"), got) } if bad := unpairedParagraphTag(got); bad != "" { t.Fatalf("仍有未配对段落标签 %q:\n%q", bad, got) } if bad := clientBubblesWithLiteralTags(got); bad != "" { t.Fatalf("按客户端契约拆出的气泡里仍有字面标签 %q:\n%q", bad, got) } } // TestNormalizeParagraphs_UnpairedTagShapes 是必测②:四种畸形形状逐条对齐口径 // (合并冗余重开 / 合并「闭标签缺失后重开」/ 丢掉尾部空开标签 / 丢掉落单闭标签)。 func TestNormalizeParagraphs_UnpairedTagShapes(t *testing.T) { t.Parallel() cases := []struct { name string in string want string }{ // (1) 双开标签:模型原样吐出(非贪婪块正则把它当「一个完整块」放过),也覆盖 // 「模型末段漏闭标签 ⇒ normalizePiece 亲手再套一层」造出来的同一种形状。 {"双开标签", "

正文。

", "

正文。

"}, {"双开标签落在多段中间", "

第一。

\n\n

第二。

", "

第一。

\n\n

第二。

"}, {"末段漏闭标签(规整器补包裹)", "

第一。

\n\n

第二。", "

第一。

\n\n

第二。

"}, // 中段漏闭标签:段首已开段落 ⇒ **只补闭、不再套一层**(段落边界因此保住; // 若套一层,配对修复会把两段合并成一段 —— 「一个 `

` = 一段」丢结构)。 {"中段漏闭标签保住段落边界", "

第一。\n\n第二。", "

第一。

\n\n

第二。

"}, // 「只补闭不补开」也保住了模型自己写的开标签(含属性)—— 不套外层就不会被 // 配对修复的「冗余重开合并」把属性一起丢掉。 {"带属性的漏闭段落保住开标签", "

正文。", "

正文。

"}, // 整条只有一个落单开标签:把它补成一个**配对**的空段落(paired),而不是把一条 // 非空消息收成空串(见 repairParagraphTags 的收尾防御)。 {"整条只有一个落单开标签", "

", "

"}, // (2) 闭标签缺失后又重开:第二个开标签之后没有内容 ⇒ 合并进退,不吞正文。 {"闭标签缺失后重开", "

正文

", "

正文

"}, // (3) 尾部落单开标签:内容区全是空白 ⇒ 丢掉(`

` 在客户端是一个空气泡)。 {"尾部落单开标签", "

正文。

", "

正文。

"}, {"尾部空开标签被丢掉", "正文。

", "

正文。

"}, // (4) 落单闭标签:没有开着的段落 ⇒ 丢掉闭标签,正文一字不动。 {"落单闭标签", "

正文。

", "

正文。

"}, {"裸正文带落单闭标签", "正文。

", "

正文。

"}, {"双开双闭", "

X

", "

X

"}, // 空段落对:内容区无正文,整对丢掉(理由同上,不吞正文)。 {"空段落对", "

正文。

\n\n

", "

正文。

\n\n"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() got := NormalizeParagraphs(c.in) if got != c.want { t.Fatalf("规整 = %q, want %q", got, c.want) } if bad := unpairedParagraphTag(got); bad != "" { t.Fatalf("仍有未配对段落标签 %q:%q", bad, got) } if bad := clientBubblesWithLiteralTags(got); bad != "" { t.Fatalf("客户端契约下气泡里仍有字面标签 %q:%q", bad, got) } }) } } // TestNormalizeParagraphs_UnpairedShapeIsIdempotent 是必测③:畸形形状经过一次规整后 // 必须**稳定**(第二次逐字相同)—— 修复前的 `正文。

` 第二次运行还会继续变(不幂等), // 这条用例把它钉住。 func TestNormalizeParagraphs_UnpairedShapeIsIdempotent(t *testing.T) { t.Parallel() shapes := []string{ reportedUnpairedReply, "

正文。

", "

正文

", "

正文。

", "

正文。

", "正文。

", "

X

", "

正文。

\n\n

", } for _, s := range shapes { once := NormalizeParagraphs(s) if twice := NormalizeParagraphs(once); twice != once { t.Fatalf("不幂等:\n in=%q\n once=%q\ntwice=%q", s, once, twice) } } } // TestNormalizeParagraphs_NonTagAngleBracketsStayVerbatim 是必测④(护栏):不是段落标签 // 的尖括号内容(残缺 ``、`1 < 2`、`a`)一律不进修复 // 逻辑,正文逐字保留。 func TestNormalizeParagraphs_NonTagAngleBracketsStayVerbatim(t *testing.T) { t.Parallel() cases := []struct { name string in string want string }{ {"小于号与裸 a1 < 2 且 a"}, {"残缺无 > 的半标签", "

残缺的半标签

残缺的半标签

"}, // `` 不是段落标签(p 后紧跟非空白)⇒ 它只是正文,因此仍按裸段落被包一层。 {" 不是段落标签", "正文", "

正文

"}, // 围栏文本整条跳过:里面的 `

` 是代码,一个字符都不许动(含不配对的情形)。 {"围栏内的未配对标签", "看这段:\n\n```html\n

X

\n```", "看这段:\n\n```html\n

X

\n```"}, // 块内的 `

` 字面量随块原样保留,不触发配对机器。 {"动作块内的字面

", "写着

两个字\n\n第二段。", "

写着

两个字

\n\n

第二段。

"}, // 自闭合

是既有合法形状:保留,且不算空段落。 {"自闭合段落保留", "

正文。

\n\n

", "

正文。

\n\n

"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() if got := NormalizeParagraphs(c.in); got != c.want { t.Fatalf("规整 = %q, want %q", got, c.want) } }) } } // clientBlockRe 是安卓端契约 P_BLOCK_RE 的**逐字口径**(非贪婪)。 var clientBlockRe = regexp.MustCompile(`(?s)]*)?>([\s\S]*?)

`) // clientBubblesWithLiteralTags 按客户端契约拆出气泡正文,返回其中**仍含字面 `

` / // `

`** 的那一条(没有则返回空串)。上报症状正是「非贪婪契约把 `

正文` 当内容 ⇒ // 用户看到字面 `

`」,因此这个助手直接复现症状,且与实现用的正则无关。 func clientBubblesWithLiteralTags(text string) string { for _, m := range clientBlockRe.FindAllStringSubmatch(text, -1) { if strings.Contains(m[2], "

") || strings.Contains(m[2], "

") { return m[2] } } return "" } // unpairedParagraphTag 返回 text 里第一个**未配对**的段落标签(没有则返回空串): // 落单的闭标签、嵌套/落单的开标签、或文本结束时仍开着的段落。 func unpairedParagraphTag(text string) string { depth := 0 for _, loc := range paragraphTagRe.FindAllStringIndex(text, -1) { tag := text[loc[0]:loc[1]] switch { case isSelfClosingTag(tag): case isClosingTag(tag): if depth == 0 { return tag } depth-- default: if depth > 0 { return tag } depth++ } } if depth > 0 { return "

" } return "" }