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" + "那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?
" // reportedUnpairedFixed 是上报原串的**目标形态**:只有那处双开标签被合并( // `正文
` ⇒ `正文
`),其余一字不动。 const reportedUnpairedFixed = "真乖♪ 这可是今天最让人家放心的一句。
\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"}, {"残缺无 > 的半标签", "
残缺的半标签
残缺的半标签
"},
// ` ` 是代码,一个字符都不许动(含不配对的情形)。
{"围栏内的未配对标签", "看这段:\n\n```html\n X X ` 字面量随块原样保留,不触发配对机器。
{"动作块内的字面 ", " 两个字 两个字 第二段。 正文。 正文。 ]*)?>([\s\S]*?) ` /
// ` 正文` 当内容 ⇒
// 用户看到字面 ` `」,因此这个助手直接复现症状,且与实现用的正则无关。
func clientBubblesWithLiteralTags(text string) string {
for _, m := range clientBlockRe.FindAllStringSubmatch(text, -1) {
if strings.Contains(m[2], " ") || strings.Contains(m[2], " "
}
return ""
}