裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。 - 新增 internal/runtime/turnreply/paragraph.go 的 NormalizeParagraphs: 把未被 <p> 包裹的段落补上 <p>(按空行分段;<action>/<narration> 段也包进 <p>, 与正确形态 <p><action>…</action>…</p> 一致) - **幂等**守门断言:已完全包裹的文本逐字不变(防二次加工) - 落点覆盖 Web 与 IM 两条落库路径(gateway/session_turn_reply.go、channelbridge/turnreply.go、 proactive.go)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉 (测试与变异详见切片报告)
181 lines
7.9 KiB
Go
181 lines
7.9 KiB
Go
package turnreply
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 本文件锁定「段落包裹规整」(paragraph.go)的判据,重点是**幂等**这一条守门断言:
|
||
// 已规范的文本再跑一次必须逐字相同 —— 否则这套规整就会把好文本二次加工坏,而它
|
||
// 跑在每一条助手回复的写入路径上。
|
||
//
|
||
// 半包样本照抄上报形状:多段裸文本 + 一段 <action>,只有最后一段带 <p>。
|
||
|
||
// halfWrappedSample 是上报的半包形状:4 段裸文本(含内联 <action> 的那段)+
|
||
// 只有最后一段带 <p>。它正是会塌成一坨的那一侧。
|
||
const halfWrappedSample = "第一段,读到头也读到尾啦♪\n\n" +
|
||
"第二段,轻轻合上纸条。\n\n" +
|
||
"第三段,纸张有点卷边了。\n\n" +
|
||
"<action>把整卷纸条摊在小桌上</action>第四段,你看这里还有个小记号。\n\n" +
|
||
"<p>第五段,我把它们按日子排好啦。</p>"
|
||
|
||
// wrappedSample 是半包样本**规整后**的规范形态(每段一个 <p>,<action> 段也包
|
||
// 在它所在的 <p> 里,只有最后一段原本就是这样)。
|
||
const wrappedSample = "<p>第一段,读到头也读到尾啦♪</p>\n\n" +
|
||
"<p>第二段,轻轻合上纸条。</p>\n\n" +
|
||
"<p>第三段,纸张有点卷边了。</p>\n\n" +
|
||
"<p><action>把整卷纸条摊在小桌上</action>第四段,你看这里还有个小记号。</p>\n\n" +
|
||
"<p>第五段,我把它们按日子排好啦。</p>"
|
||
|
||
// TestNormalizeParagraphs_HalfWrappedGetsEveryParagraphWrapped 是必测①:半包样本
|
||
// 规整后**每一段**都被 <p> 包裹 —— 包括 <action> 那一段(它必须与同段的正文一起
|
||
// 落在同一个 <p> 里,而不是被拆出去或漏在外面)。
|
||
func TestNormalizeParagraphs_HalfWrappedGetsEveryParagraphWrapped(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
got := NormalizeParagraphs(halfWrappedSample)
|
||
if got != wrappedSample {
|
||
t.Fatalf("规整结果 =\n%q\nwant\n%q", got, wrappedSample)
|
||
}
|
||
// 唯一的判据是「<p> 块之外有没有非空白文本」—— 客户端的塌陷规则正是按它生效。
|
||
if outside := bareTextOutsideParagraphs(got); outside != "" {
|
||
t.Fatalf("<p> 块之外仍有裸文本 %q —— 客户端仍会塌成一坨", outside)
|
||
}
|
||
// <action> 段没有被拆开(开闭标签仍在同一段里,且与同段正文同在一个 <p>)。
|
||
const action = "<action>把整卷纸条摊在小桌上</action>第四段,你看这里还有个小记号。"
|
||
if !strings.Contains(got, "<p>"+action+"</p>") {
|
||
t.Fatalf("<action> 段没有被原样包进同一个 <p>:\n%q", got)
|
||
}
|
||
}
|
||
|
||
// TestNormalizeParagraphs_Idempotent 是必测②(守门断言):已规范的文本再跑一次
|
||
// 必须**逐字相同**。它是这套规整能跑在写入路径上的前提 —— 幂等一旦破掉,每次
|
||
// 读改写都会往好文本上再叠一层标签。
|
||
func TestNormalizeParagraphs_Idempotent(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
canonical := []struct {
|
||
name string
|
||
text string
|
||
}{
|
||
{"半包样本的规范形态", wrappedSample},
|
||
{"上报的正确形态", "<p><action>把整卷纸条…</action>读到头也读到尾啦♪</p>"},
|
||
{"多段规范文本", "<p>第一段。</p>\n\n<p>第二段。</p>"},
|
||
{"规范文本但空行更多", "<p>第一段。</p>\n\n\n\n<p>第二段。</p>"},
|
||
{"规范文本但段间有缩进空白", "<p>第一段。</p>\n \n<p>第二段。</p>"},
|
||
{"单段规范文本(带外缘空白)", " <p>就一句。</p>\n"},
|
||
{"段内多行保持不动", "<p>第一行\n第二行</p>\n\n<p>下一段。</p>"},
|
||
{"动作段内的空行不被切开", "<p><action>前半;\n\n后半。</action>说完啦。</p>"},
|
||
{"自闭合段落", "<p>正文。</p>\n\n<p/>"},
|
||
{"空文本", ""},
|
||
{"纯空白", " \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,
|
||
"<p><action>把整卷纸条…</action>读到头也读到尾啦♪</p>",
|
||
"<p>第一段。</p>\n\n<p>第二段。</p>",
|
||
"<p>第一段。</p>\n\n\n\n<p>第二段。</p>",
|
||
"<p>第一段。</p>\n \n<p>第二段。</p>",
|
||
" <p>就一句。</p>\n",
|
||
"<p>第一行\n第二行</p>\n\n<p>下一段。</p>",
|
||
"<p><action>前半;\n\n后半。</action>说完啦。</p>",
|
||
"<p>正文。</p>\n\n<p/>",
|
||
}
|
||
for _, text := range canonical {
|
||
if got := NormalizeParagraphs(text); got != text {
|
||
t.Fatalf("已规范的文本被改动:\n in=%q\nout=%q", text, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestNormalizeParagraphs_BareTextGetsWrapped 是必测③:完全没有任何 <p> 的裸文本
|
||
// 也要被规整(它现在靠客户端的空行回退才拆得开,一旦别处混进一个 <p> 就会塌)。
|
||
func TestNormalizeParagraphs_BareTextGetsWrapped(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
in string
|
||
want string
|
||
}{
|
||
{"多段裸文本", "第一段。\n\n第二段。", "<p>第一段。</p>\n\n<p>第二段。</p>"},
|
||
{"单段裸文本", "就一句。", "<p>就一句。</p>"},
|
||
{"带动作的裸文本", "<action>弯起眼睛</action>当然看得到呀。\n\n第二段。",
|
||
"<p><action>弯起眼睛</action>当然看得到呀。</p>\n\n<p>第二段。</p>"},
|
||
{"独立动作段", "正文。\n\n<action>拍拍你</action>",
|
||
"<p>正文。</p>\n\n<p><action>拍拍你</action></p>"},
|
||
{"旁白段", "<narration>心里有点甜</narration>\n\n正文。",
|
||
"<p><narration>心里有点甜</narration></p>\n\n<p>正文。</p>"},
|
||
{"段内单换行保持在同一段", "第一行\n第二行\n\n第二段。",
|
||
"<p>第一行\n第二行</p>\n\n<p>第二段。</p>"},
|
||
}
|
||
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 守住另一条边界:<action>
|
||
// 块**跨空行**时绝不能被切成分属两段 —— 那会让 Web 与 IM 都看到野生标签。
|
||
func TestNormalizeParagraphs_ActionAcrossBlankLineStaysWhole(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
const in = "<action>把整卷纸条摊开;\n\n又轻轻卷回去。</action>读到头也读到尾啦♪\n\n<p>下一段。</p>"
|
||
const want = "<p><action>把整卷纸条摊开;\n\n又轻轻卷回去。</action>读到头也读到尾啦♪</p>\n\n<p>下一段。</p>"
|
||
if got := NormalizeParagraphs(in); got != want {
|
||
t.Fatalf("规整 = %q, want %q", got, want)
|
||
}
|
||
}
|
||
|
||
// bareTextOutsideParagraphs 返回所有落在完整 <p> 块之外的**非空白**文本,用于
|
||
// 断言「没有裸文本漏在 <p> 之外」。
|
||
func bareTextOutsideParagraphs(text string) string {
|
||
rest := paragraphBlockRe.ReplaceAllString(text, " ")
|
||
return strings.TrimSpace(rest)
|
||
}
|