package turnreply import ( "strings" "testing" ) // 本文件锁定「段落包裹规整」(paragraph.go)的判据,重点是**幂等**这一条守门断言: // 已规范的文本再跑一次必须逐字相同 —— 否则这套规整就会把好文本二次加工坏,而它 // 跑在每一条助手回复的写入路径上。 // // 半包样本照抄上报形状:多段裸文本 + 一段 ,只有最后一段带

。 // halfWrappedSample 是上报的半包形状:4 段裸文本(含内联 的那段)+ // 只有最后一段带

。它正是会塌成一坨的那一侧。 const halfWrappedSample = "第一段,读到头也读到尾啦♪\n\n" + "第二段,轻轻合上纸条。\n\n" + "第三段,纸张有点卷边了。\n\n" + "把整卷纸条摊在小桌上第四段,你看这里还有个小记号。\n\n" + "

第五段,我把它们按日子排好啦。

" // wrappedSample 是半包样本**规整后**的规范形态(每段一个

, 段也包 // 在它所在的

里,只有最后一段原本就是这样)。 const wrappedSample = "

第一段,读到头也读到尾啦♪

\n\n" + "

第二段,轻轻合上纸条。

\n\n" + "

第三段,纸张有点卷边了。

\n\n" + "

把整卷纸条摊在小桌上第四段,你看这里还有个小记号。

\n\n" + "

第五段,我把它们按日子排好啦。

" // TestNormalizeParagraphs_HalfWrappedGetsEveryParagraphWrapped 是必测①:半包样本 // 规整后**每一段**都被

包裹 —— 包括 那一段(它必须与同段的正文一起 // 落在同一个

里,而不是被拆出去或漏在外面)。 func TestNormalizeParagraphs_HalfWrappedGetsEveryParagraphWrapped(t *testing.T) { t.Parallel() got := NormalizeParagraphs(halfWrappedSample) if got != wrappedSample { t.Fatalf("规整结果 =\n%q\nwant\n%q", got, wrappedSample) } // 唯一的判据是「

块之外有没有非空白文本」—— 客户端的塌陷规则正是按它生效。 if outside := bareTextOutsideParagraphs(got); outside != "" { t.Fatalf("

块之外仍有裸文本 %q —— 客户端仍会塌成一坨", outside) } // 段没有被拆开(开闭标签仍在同一段里,且与同段正文同在一个

)。 const action = "把整卷纸条摊在小桌上第四段,你看这里还有个小记号。" if !strings.Contains(got, "

"+action+"

") { t.Fatalf(" 段没有被原样包进同一个

:\n%q", got) } } // TestNormalizeParagraphs_Idempotent 是必测②(守门断言):已规范的文本再跑一次 // 必须**逐字相同**。它是这套规整能跑在写入路径上的前提 —— 幂等一旦破掉,每次 // 读改写都会往好文本上再叠一层标签。 func TestNormalizeParagraphs_Idempotent(t *testing.T) { t.Parallel() canonical := []struct { name string text string }{ {"半包样本的规范形态", wrappedSample}, {"上报的正确形态", "

把整卷纸条…读到头也读到尾啦♪

"}, {"多段规范文本", "

第一段。

\n\n

第二段。

"}, {"规范文本但空行更多", "

第一段。

\n\n\n\n

第二段。

"}, {"规范文本但段间有缩进空白", "

第一段。

\n \n

第二段。

"}, {"单段规范文本(带外缘空白)", "

就一句。

\n"}, {"段内多行保持不动", "

第一行\n第二行

\n\n

下一段。

"}, {"动作段内的空行不被切开", "

前半;\n\n后半。说完啦。

"}, {"自闭合段落", "

正文。

\n\n

"}, {"空文本", ""}, {"纯空白", " \n\t "}, } for _, c := range canonical { t.Run(c.name, func(t *testing.T) { t.Parallel() once := NormalizeParagraphs(c.text) if twice := NormalizeParagraphs(once); twice != once { t.Fatalf("不幂等:第二次运行改动了文本\n once=%q\ntwice=%q", once, twice) } }) } } // TestNormalizeParagraphs_CanonicalTextIsByteIdentical 是必测②的另一半:**已完全 // 包裹**的文本必须逐字不变(不只是「第二次运行相同」,而是「一次都不许改」)。 func TestNormalizeParagraphs_CanonicalTextIsByteIdentical(t *testing.T) { t.Parallel() canonical := []string{ wrappedSample, "

把整卷纸条…读到头也读到尾啦♪

", "

第一段。

\n\n

第二段。

", "

第一段。

\n\n\n\n

第二段。

", "

第一段。

\n \n

第二段。

", "

就一句。

\n", "

第一行\n第二行

\n\n

下一段。

", "

前半;\n\n后半。说完啦。

", "

正文。

\n\n

", } for _, text := range canonical { if got := NormalizeParagraphs(text); got != text { t.Fatalf("已规范的文本被改动:\n in=%q\nout=%q", text, got) } } } // TestNormalizeParagraphs_BareTextGetsWrapped 是必测③:完全没有任何

的裸文本 // 也要被规整(它现在靠客户端的空行回退才拆得开,一旦别处混进一个

就会塌)。 func TestNormalizeParagraphs_BareTextGetsWrapped(t *testing.T) { t.Parallel() cases := []struct { name string in string want string }{ {"多段裸文本", "第一段。\n\n第二段。", "

第一段。

\n\n

第二段。

"}, {"单段裸文本", "就一句。", "

就一句。

"}, {"带动作的裸文本", "弯起眼睛当然看得到呀。\n\n第二段。", "

弯起眼睛当然看得到呀。

\n\n

第二段。

"}, {"独立动作段", "正文。\n\n拍拍你", "

正文。

\n\n

拍拍你

"}, {"旁白段", "心里有点甜\n\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) } }) } } // TestNormalizeParagraphs_BlankAndFencedStayUntouched 是必测④与代码块边界:空文本、 // 纯空白一字不动;含 ``` 围栏的文本**整条保守跳过**(把围栏里的空行当段落切开, // 会把一段代码拆成互不相连的段落,比不规整更糟)。 func TestNormalizeParagraphs_BlankAndFencedStayUntouched(t *testing.T) { t.Parallel() cases := []struct { name string text string }{ {"空文本", ""}, {"纯空白", " \n\t\n"}, {"空行围栏", "先看这段:\n\n```go\nfunc main() {}\n\nfunc other() {}\n```\n\n就这样。"}, {"行内围栏", "看看 ```code``` 这个词。\n\n第二段。"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() if got := NormalizeParagraphs(c.text); got != c.text { t.Fatalf("不该被改动:\n in=%q\nout=%q", c.text, got) } }) } } // TestNormalizeParagraphs_ActionAcrossBlankLineStaysWhole 守住另一条边界: // 块**跨空行**时绝不能被切成分属两段 —— 那会让 Web 与 IM 都看到野生标签。 func TestNormalizeParagraphs_ActionAcrossBlankLineStaysWhole(t *testing.T) { t.Parallel() const in = "把整卷纸条摊开;\n\n又轻轻卷回去。读到头也读到尾啦♪\n\n

下一段。

" const want = "

把整卷纸条摊开;\n\n又轻轻卷回去。读到头也读到尾啦♪

\n\n

下一段。

" if got := NormalizeParagraphs(in); got != want { t.Fatalf("规整 = %q, want %q", got, want) } } // bareTextOutsideParagraphs 返回所有落在完整

块之外的**非空白**文本,用于 // 断言「没有裸文本漏在

之外」。 func bareTextOutsideParagraphs(text string) string { rest := paragraphBlockRe.ReplaceAllString(text, " ") return strings.TrimSpace(rest) }