复审 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)。
234 lines
11 KiB
Go
234 lines
11 KiB
Go
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)
|
||
}
|
||
}
|