Files
Cyrene_v2/internal/runtime/turnreply/paragraph_test.go
T
AskaEth 5de708a560
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,channelbridge,turnreply): p 标签半包写入侧规整(修「整条塌成一段」)
裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。
- 新增 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)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉
(测试与变异详见切片报告)
2026-10-05 10:56:39 +08:00

181 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}