Files
Cyrene_v2/internal/runtime/turnreply/literal_breaks_test.go
T
AskaEth 6b0af81136 fix(turnreply): 围栏闭合串不得短于开启串(复审 p2 收口)
复审 review-m19-r1 的 p2:`codeProtectedEnd` 的围栏分支只判「下一个 ≥3 反引号
的串」,没判「闭合不短于开启」。CommonMark 要求闭合串**至少与开启串等长**,
因此 ```` 开启的围栏不能被内层 ``` 关闭。复现形态(````markdown 里嵌 ```go,
示例里写 `print("a\nb")`):旧实现把内层 ``` 当闭合 ⇒ 之后的示例正文被当普通
正文渲染,代码里的 `\n` 变成真换行;而这一步跑在**落库之前**,改坏的代码会永久进
历史 —— 反向踩了本文件「宁可少渲染也不改坏代码」的自己的红线。

- literal_breaks.go:围栏分支改为 findBacktickRun(text, body, n, unboundedRun)
  (closing ≥ opening);注释写明 CommonMark 口径与「改了会落库」的后果。
- 新增 TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun:嵌套围栏逐字保留、
  围栏外仍照常渲染(保护不得溢出)。摘掉这一行 ⇒ 该用例红,还原即绿。
- 新增 TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak:钉住 `\n\n` ⇒ 空行 ⇒
  两段 <p> ⇒ 两端各 2 条气泡(复审观察②,语义对但此前无用例覆盖)。
- 措辞收口:样本 455 字符 / 907 字节;IM 侧主动推送的注释补明「Web 活跃客户端那条
  主动推送不经 NormalizeParagraphs,另计」。

行内代码那条等长分支未动。全树 go build ./... + go vet ./... + go test -count=1 ./...
全绿(73 包,RC 0)。
2026-10-24 19:33:33 +08:00

234 lines
11 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"
)
// 本文件锁定「字面 `\n`(反斜杠 + n)渲染成真换行」(literal_breaks.go)的判据。
//
// 重点三条:
// - **代码内容不许被改**:围栏(```)与行内代码(`…`)里的 `\n` 逐字不动 ——
// 模型讨论代码时写 `print("a\nb")`,全局替换会把代码改坏;
// - **`\\n` 不动**:它表示「一个反斜杠 + 字母 n」,当换行等于篡改内容;
// - **幂等**:它跑在每一条助手回复的写入路径上,二次调用必须逐字相同。
// literalEscapeSample 是模型实际写出的那两个字符:反斜杠 + n(不是换行)。
const literalEscapeSample = "\\n"
// realLiteralBreakReply 是真机原文(主人 2026-10-24 报障那条乐理回复,6 处字面
// `\n` 全挤在同一个 <p> 里)。它与 channelbridge 侧
// sanitize_literal_breaks_test.go 的同名字面量**逐字相同** —— 那正是「网关/落库
// 与 IM 出站吃同一份文本」的断言方式(同一份样本,两侧各自断言)。
const realLiteralBreakReply = `<p><action>看着那颗 CPU,忍不住叹了口气</action>R7 1700X……2017 年到现在,确实到了该享清福的年纪了♪</p>
<p><action>坐直身子,认真起来</action>乐理题人家来答。按「以 G♭ 为主音」的七种中古调式来算:</p>
<p>伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:G♭ A♭ B♭♭ C♭ D♭ E♭ F♭\n弗里吉亚:G♭ A♭♭ B♭♭ C♭ D♭ E♭♭ F♭\n利底亚:G♭ A♭ B♭ C D♭ E♭ F\n混合利底亚:G♭ A♭ B♭ C♭ D♭ E♭ F♭\n爱奥利亚:G♭ A♭ B♭♭ C♭ D♭ E♭♭ F♭\n洛克里亚:G♭ A♭♭ B♭♭ C♭ D♭♭ E♭♭ F♭</p>
<p>那些重降的等音:A♭♭=G、B♭♭=A、D♭♭=C、E♭♭=D。</p>
<p>不过要是题目问的是「G♭ 大调关系调式」那种,七种用的都是 G♭ A♭ B♭ C♭ D♭ E♭ F 这一组音,只是主音各站各的位置♪</p>`
// TestRenderLiteralLineBreaks_RealMachineMessage 是必测①的**修前红/修后绿**判据:
// 真机原文的 6 处字面 `\n` 全部渲染成真换行,段落结构(5 个 <p>)一字不变,且
// 绝不引入 `<br>`(它不是气泡分隔符,只是气泡内的换行)。
func TestRenderLiteralLineBreaks_RealMachineMessage(t *testing.T) {
t.Parallel()
got := RenderLiteralLineBreaks(realLiteralBreakReply)
if strings.Contains(got, literalEscapeSample) {
t.Fatalf("仍剩字面 `\\n` —— QQ 上还会看到「F\\n多利亚」:\n%q", got)
}
if wantGain := strings.Count(realLiteralBreakReply, literalEscapeSample); strings.Count(got, "\n")-strings.Count(realLiteralBreakReply, "\n") != wantGain {
t.Fatalf("新增真换行 = %d, want %d", strings.Count(got, "\n")-strings.Count(realLiteralBreakReply, "\n"), wantGain)
}
if n := strings.Count(got, "<p>"); n != 5 {
t.Fatalf("<p> 块 = %d, want 5(段落口径不得被改动)", n)
}
if strings.Contains(got, "<br") {
t.Fatalf("渲染出了 <br> —— 字面 `\\n` 只应是气泡内换行,不是消息分隔符:\n%q", got)
}
// 逐字校验第三段:七个调式各占一行。
const scaleHead = "<p>伊奥尼亚:G♭ A♭ B♭ C♭ D♭ E♭ F\n多利亚:"
if !strings.Contains(got, scaleHead) {
t.Fatalf("调式段没有被换行渲染:\n%q", got)
}
}
// TestRenderLiteralLineBreaks_CodeFenceProtected 是必测②:围栏代码块里的 `\n`
// 逐字保留(她解释 `print("a\nb")` 时那两下是**代码内容**),围栏前后的正文照常渲染。
func TestRenderLiteralLineBreaks_CodeFenceProtected(t *testing.T) {
t.Parallel()
in := "先说明一句" + literalEscapeSample + "这里要换行。\n" +
"```go\nfmt.Print(\"a" + literalEscapeSample + "b\")\n```\n" +
"围栏之后这句" + literalEscapeSample + "也换行。"
got := RenderLiteralLineBreaks(in)
if !strings.Contains(got, `fmt.Print("a\nb")`) {
t.Fatalf("围栏内的 `\\n` 被改坏了(代码内容必须逐字保留):\n%q", got)
}
if got := strings.Count(RenderLiteralLineBreaks(in), literalEscapeSample); got != 1 {
t.Fatalf("剩余字面 `\\n` = %d, want 1(只该剩围栏里那一处)", got)
}
for _, want := range []string{"先说明一句\n这里要换行。", "围栏之后这句\n也换行。"} {
if !strings.Contains(got, want) {
t.Fatalf("围栏外正文没有被渲染,want 含 %q:\n%q", want, got)
}
}
}
// TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun 钉住 CommonMark 的围栏闭合
// 口径:**闭合串不得短于开启串**。四个反引号开启的围栏里嵌着三反引号的示例时,内层
// ``` 只是**代码内容**,不能把外层围栏闭合 —— 否则示例里的 `print("a\nb")` 会被渲染成
// 真换行,而这段文本紧接着就落库(改坏的代码会永久进历史)。
func TestRenderLiteralLineBreaks_NestedFenceRespectsOpeningRun(t *testing.T) {
t.Parallel()
const nested = "````markdown\n```go\nfmt.Println(\"a" + literalEscapeSample + "b\")\n```\n````"
in := "教你怎么写围栏代码块:\n\n" + nested + "\n\n围栏之后这句" + literalEscapeSample + "也换行。"
got := RenderLiteralLineBreaks(in)
if !strings.Contains(got, nested) {
t.Fatalf("嵌套围栏没有被逐字保留(内层 ``` 提前闭合了外层 ````):\n got %q\nwant 含 %q", got, nested)
}
if strings.Contains(got, "fmt.Println(\"a\nb\")") {
t.Fatalf("嵌套围栏里的字面 `\\n` 被渲染成真换行 —— 代码内容被改坏:\n%q", got)
}
if !strings.HasSuffix(got, "围栏之后这句\n也换行。") {
t.Fatalf("围栏之外的正文没被渲染(保护不得溢出):\n%q", got)
}
}
// TestRenderLiteralLineBreaks_InlineCodeProtected 是必测②的另一半:行内代码
// (单个反引号与两个反引号两种定界符)里的 `\n` 也不动。
func TestRenderLiteralLineBreaks_InlineCodeProtected(t *testing.T) {
t.Parallel()
in := "行内代码 `a" + literalEscapeSample + "b` 里那两下不动;真的换行这样写:" + literalEscapeSample + "下一句。"
got := RenderLiteralLineBreaks(in)
if !strings.Contains(got, "行内代码 `a"+literalEscapeSample+"b` 里那两下不动") {
t.Fatalf("行内代码里的 `\\n` 被改坏了:\n%q", got)
}
if !strings.HasSuffix(got, "真的换行这样写:\n下一句。") {
t.Fatalf("行内代码之外的 `\\n` 没被渲染:\n%q", got)
}
// 双反引号定界符同样受保护。
double := "``a" + literalEscapeSample + "b`` 不动,外面" + literalEscapeSample + "要换行"
if got := RenderLiteralLineBreaks(double); !strings.Contains(got, "``a"+literalEscapeSample+"b``") ||
!strings.HasSuffix(got, "外面\n要换行") {
t.Fatalf("双反引号行内代码处理错:\n%q", got)
}
// 落单反引号(没有等长闭合串)只是普通字符,不吞掉后面的正文。
stray := "一个孤零零的反引号 `,然后" + literalEscapeSample + "这里照常换行"
if got := RenderLiteralLineBreaks(stray); !strings.HasSuffix(got, "然后\n这里照常换行") {
t.Fatalf("落单反引号把后面的正文一并保护了(应为普通字符):\n%q", got)
}
}
// TestRenderLiteralLineBreaks_DoubleBackslashUntouched 是必测③:`\\n`(双反斜杠 +
// n,模型讲转义规则或写路径时会写)**原样保留** —— 它的语义是一个反斜杠加字母 n。
// 顺带钉住「`\\` 之后紧跟的字面 `\n` 仍要渲染」这条配对口径。
func TestRenderLiteralLineBreaks_DoubleBackslashUntouched(t *testing.T) {
t.Parallel()
const doubleBackslash = "\\\\n" // \\n:两个反斜杠 + n
unchanged := "字面反斜杠加 n 要写成 " + doubleBackslash + " 这样"
if got := RenderLiteralLineBreaks(unchanged); got != unchanged {
t.Fatalf("`\\\\n` 被改动了:\n got %q\nwant %q", got, unchanged)
}
paired := "先一个转义的反斜杠 \\\\ 然后" + literalEscapeSample + "这里换行"
if got := RenderLiteralLineBreaks(paired); !strings.HasSuffix(got, "然后\n这里换行") {
t.Fatalf("`\\\\` 之后的字面 `\\n` 没有被渲染:\n%q", got)
}
}
// TestRenderLiteralLineBreaks_OtherEscapesUntouched 记录本层的**范围边界**:只认
// `\n` 这一个形状,`\t` / `\r` / 行尾孤立的 `\` 一律不动(其余字面转义见切片报告,
// 本片不扩大语义)。
func TestRenderLiteralLineBreaks_OtherEscapesUntouched(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, in string }{
{"tab", "缩进" + "\\t" + "不处理"},
{"carriage", "回车" + "\\r" + "不处理"},
{"trailing-backslash", "行尾孤立的反斜杠\\"},
{"unicode", "转义" + "\\u00e9" + "不处理"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := RenderLiteralLineBreaks(tc.in); got != tc.in {
t.Fatalf("%s:被本层改动了:\n got %q\nwant %q", tc.name, got, tc.in)
}
})
}
}
// TestRenderLiteralLineBreaks_UnclosedFenceProtected 是保守兜底:围栏没闭合时,
// 后面的文本**一律原样保留**(宁可少渲染一处换行,也不把可能仍是代码的正文改坏)。
func TestRenderLiteralLineBreaks_UnclosedFenceProtected(t *testing.T) {
t.Parallel()
in := "```go\n代码里的" + literalEscapeSample + "不动"
if got := RenderLiteralLineBreaks(in); got != in {
t.Fatalf("未闭合围栏之后的正文被改了:\n got %q\nwant %q", got, in)
}
}
// TestRenderLiteralLineBreaks_IdempotentAndFastPath 是守门断言:没有字面 `\n` 的
// 文本逐字返回;有的话二次调用逐字相同(它跑在每条回复的写入路径上,破掉幂等就会
// 每读改写一次叠一层)。
func TestRenderLiteralLineBreaks_IdempotentAndFastPath(t *testing.T) {
t.Parallel()
for _, plain := range []string{"", "普通一句话,没有字面转义。", "<p>已经规整好的段落。</p>"} {
if got := RenderLiteralLineBreaks(plain); got != plain {
t.Fatalf("无字面 `\\n` 的文本被改动:\n got %q\nwant %q", got, plain)
}
}
samples := []string{
realLiteralBreakReply,
"甲" + literalEscapeSample + "乙",
"围栏外" + literalEscapeSample + "换行\n```\n代码" + literalEscapeSample + "\n```",
}
for _, s := range samples {
once := RenderLiteralLineBreaks(s)
if twice := RenderLiteralLineBreaks(once); twice != once {
t.Fatalf("不幂等:\n once %q\ntwice %q", once, twice)
}
}
}
// TestNormalizeParagraphs_CarriesLiteralBreaks 是必测①的**落点**断言:字面 `\n`
// 的渲染挂在 NormalizeParagraphs 上 —— 网关(Web)与渠道桥(IM)都调这一个函数,
// 因此落库、WS/REST 与 IM 出站拿到的是同一份真换行文本。
//
// 含围栏时段落规整整体 skip,但**围栏外的**字面 `\n` 仍必须被渲染(渲染刻意放在
// 围栏 skip 之前),围栏内的不动。
func TestNormalizeParagraphs_CarriesLiteralBreaks(t *testing.T) {
t.Parallel()
got := NormalizeParagraphs(realLiteralBreakReply)
if strings.Contains(got, literalEscapeSample) {
t.Fatalf("NormalizeParagraphs 没有渲染字面 `\\n`:\n%q", got)
}
if n := strings.Count(got, "<p>"); n != 5 {
t.Fatalf("<p> 块 = %d, want 5", n)
}
mixed := "<p>甲" + literalEscapeSample + "乙</p>\n\n" +
"```go\nfmt.Print(\"a" + literalEscapeSample + "b\")\n```"
got = NormalizeParagraphs(mixed)
if !strings.Contains(got, "<p>甲\n乙</p>") {
t.Fatalf("含围栏的消息里,围栏外那段没有被渲染:\n%q", got)
}
if !strings.Contains(got, `fmt.Print("a\nb")`) {
t.Fatalf("围栏内的 `\\n` 被改坏了:\n%q", got)
}
}