复审 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)。
179 lines
8.9 KiB
Go
179 lines
8.9 KiB
Go
package channelbridge
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||
)
|
||
|
||
// 本文件锁定「字面 `\n`(反斜杠 + n)渲染成真换行」在 **IM 侧真流水线**上的落点,
|
||
// 两件必须同时成立的事:
|
||
// 1. **落库(= Web 主会话镜像)与出站气泡吃同一份文本** —— 主人私聊的回复会镜像进
|
||
// Web 主会话,若只修出站,面板上仍会看到字面 `\n`(那正是这条修复的判据);
|
||
// 2. 🔴 **`<br>` 仍是气泡分隔符,字面 `\n` 只做气泡内换行** —— 把字面 `\n` 升级成
|
||
// 气泡切分会把一条列表拆成七条消息,比原缺陷更吵。
|
||
//
|
||
// 样本是**真机原文**(主人 2026-10-24 报障那条乐理回复,库里的 assistant 行,
|
||
// 6 处字面 `\n` 全挤在同一个 <p> 里)。
|
||
|
||
// literalEscape 是模型写出、而清洗链不认的那两个字符:反斜杠 + n。
|
||
const literalEscape = `\n`
|
||
|
||
// realLiteralBreakReply 是上报的原文,逐字取自 chat_messages(455 字符 / 907 字节、
|
||
// 5 个 <p>、6 处字面 `\n`、不含反引号)。断言依赖这几条性质,改样本时必须同步。
|
||
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>`
|
||
|
||
// TestTurnReply_LiteralBreakRenderedForBothSides 是必测①(真机原文):修复前这条
|
||
// 回复的第三个 <p> 是一行字面 `F\n多利亚`;修复后同一个气泡里是**七行**,而落库
|
||
// 镜像与出站气泡是同一份文本。
|
||
func TestTurnReply_LiteralBreakRenderedForBothSides(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
chat := &stubChatter{res: &loop.Result{Text: realLiteralBreakReply}}
|
||
b, ad, hist := replyTurnBridge(t, chat)
|
||
b.bubbleGapFn = func() time.Duration { return 0 }
|
||
|
||
b.process(context.Background(), directMessage())
|
||
|
||
// ① 落库(= Web 主会话镜像):6 处字面 `\n` 全部变成真换行,段落结构一字不变。
|
||
want := strings.ReplaceAll(realLiteralBreakReply, literalEscape, "\n")
|
||
rec := assertAssistantReply(t, hist, want)
|
||
if got := strings.Count(rec.Content, literalEscape); got != 0 {
|
||
t.Fatalf("落库文本仍有 %d 处字面 `\\n` —— Web 面板上仍会显示 F\\n多利亚", got)
|
||
}
|
||
// 段落结构一字不变:仍是 5 个 <p>(字面 `\n` 绝不升级成段落或 <br>)。
|
||
if got := strings.Count(rec.Content, "<p>"); got != 5 {
|
||
t.Fatalf("落库文本 <p> 块 = %d, want 5(段落口径不得被改动)", got)
|
||
}
|
||
wantLines := strings.Count(realLiteralBreakReply, "\n") + 6
|
||
if got := strings.Count(rec.Content, "\n"); got != wantLines {
|
||
t.Fatalf("落库文本真换行 = %d, want %d(6 处字面 `\\n` 应各变成一个换行)", got, wantLines)
|
||
}
|
||
|
||
// ② 出站:5 条气泡(<p> 一级、<br> 二级的既有语义不变),第三条是七行。
|
||
if len(ad.sends) != 5 {
|
||
t.Fatalf("出站气泡 = %d 条, want 5(字面 `\\n` 绝不能切成消息): %+v", len(ad.sends), ad.sends)
|
||
}
|
||
scale := ad.sends[2].Content
|
||
lines := strings.Split(scale, "\n")
|
||
if len(lines) != 7 {
|
||
t.Fatalf("调式气泡 = %d 行, want 7(修前是 1 行字面 F\\n多利亚): %q", len(lines), scale)
|
||
}
|
||
if !strings.HasPrefix(lines[0], "伊奥尼亚:") || !strings.HasPrefix(lines[6], "洛克里亚:") {
|
||
t.Fatalf("调式气泡首尾行 = %q / %q, want 伊奥尼亚… / 洛克里亚…", lines[0], lines[6])
|
||
}
|
||
for i, sent := range ad.sends {
|
||
if strings.Contains(sent.Content, literalEscape) {
|
||
t.Fatalf("气泡[%d] 仍有字面 `\\n`: %q", i, sent.Content)
|
||
}
|
||
if strings.Contains(sent.Content, "<br") {
|
||
t.Fatalf("气泡[%d] 出现了 <br> —— 字面 `\\n` 只应做气泡内换行: %q", i, sent.Content)
|
||
}
|
||
}
|
||
|
||
// ③ 同源:出站气泡就是落库镜像里那一段逐字(Web 与 IM 没有第二份加工)。
|
||
if !strings.Contains(rec.Content, scale) {
|
||
t.Fatalf("出站气泡不在落库镜像里 ⇒ 两端不是同一份文本:\n气泡 = %q\n落库 = %q", scale, rec.Content)
|
||
}
|
||
}
|
||
|
||
// TestTurnReply_LiteralBreakKeepsBreakTagBubbles 是必测⑤(既有语义不变):`<br>` 仍
|
||
// 是**气泡分隔符**(两条消息),而字面 `\n` 只在每个气泡内部换行 —— 二级切分的
|
||
// 判据一个字节都没动。
|
||
func TestTurnReply_LiteralBreakKeepsBreakTagBubbles(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
chat := &stubChatter{res: &loop.Result{Text: "甲" + literalEscape + "乙<br>丙" + literalEscape + "丁"}}
|
||
b, ad, hist := replyTurnBridge(t, chat)
|
||
b.bubbleGapFn = func() time.Duration { return 0 }
|
||
|
||
b.process(context.Background(), directMessage())
|
||
|
||
assertAssistantReply(t, hist, "<p>甲\n乙<br>丙\n丁</p>")
|
||
if len(ad.sends) != 2 {
|
||
t.Fatalf("出站气泡 = %d 条, want 2(<br> 仍是消息分隔符): %+v", len(ad.sends), ad.sends)
|
||
}
|
||
if got, want := ad.sends[0].Content, "甲\n乙"; got != want {
|
||
t.Fatalf("气泡[0] = %q, want %q(气泡内换行)", got, want)
|
||
}
|
||
if got, want := ad.sends[1].Content, "丙\n丁"; got != want {
|
||
t.Fatalf("气泡[1] = %q, want %q(气泡内换行)", got, want)
|
||
}
|
||
}
|
||
|
||
// TestSendProactive_LiteralBreakRenderedForBothSides 覆盖 **IM 侧**的第三个助手文本
|
||
// 产出点:渠道桥的主动推送(backthink)与回合回复走同一个 NormalizeParagraphs,因此
|
||
// 这条出口上也被修好 —— 出站是同一份真换行文本,镜像进 Web 主会话的也是它。
|
||
//
|
||
// ⚠️ 范围边界:**Web 活跃客户端**那条主动推送(gateway 的 proactive →
|
||
// proactive_persist)**不经** NormalizeParagraphs,字面 `\n` 在那条路由上仍原样进库;
|
||
// 那是既有取舍、不属本片 scope(已由塔登记为后续切片)。本测试只断言 IM 这一侧。
|
||
func TestSendProactive_LiteralBreakRenderedForBothSides(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
const acct = "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat"
|
||
weixin := &fakeAdapter{name: "weixin-ilink", connected: true}
|
||
owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "weixin-ilink", AccountID: acct}}}
|
||
hist := &captureHistory{}
|
||
b := newProactiveBridgeHist(t, owner, hist, weixin)
|
||
b.bubbleGapFn = func() time.Duration { return 0 }
|
||
|
||
push := "第一行" + literalEscape + "第二行"
|
||
if err := b.SendProactive(context.Background(), proactiveMsg(push)); err != nil {
|
||
t.Fatalf("SendProactive: %v", err)
|
||
}
|
||
if len(weixin.sends) != 1 {
|
||
t.Fatalf("weixin 气泡 = %d 条, want 1(字面 `\\n` 不切气泡): %+v", len(weixin.sends), weixin.sends)
|
||
}
|
||
if got, want := weixin.sends[0].Content, "第一行\n第二行"; got != want {
|
||
t.Fatalf("推送气泡 = %q, want %q", got, want)
|
||
}
|
||
recs := hist.records()
|
||
if len(recs) != 1 {
|
||
t.Fatalf("镜像进主会话 = %d 条, want 1: %+v", len(recs), recs)
|
||
}
|
||
if got, want := recs[0].Content, "<p>第一行\n第二行</p>"; got != want {
|
||
t.Fatalf("镜像内容 = %q, want %q(与出站同一份)", got, want)
|
||
}
|
||
}
|
||
|
||
// TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak 钉住一个**口径边界**(复审观察
|
||
// ②):两个连续的字面 `\n` 渲染后就是一个空行,而空行在写入侧规整里是**段落分隔** ⇒
|
||
// 两段 `<p>` ⇒ 两端各两条气泡。
|
||
//
|
||
// 语义上说得通(空行 = 段落),但它与「字面 `\n` 只做气泡内换行」不是同一句话 ——
|
||
// 这里显式钉住,让日后任何改动**有意**改这个气泡数,而不是悄悄变。
|
||
func TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
chat := &stubChatter{res: &loop.Result{Text: "甲" + literalEscape + literalEscape + "乙"}}
|
||
b, ad, hist := replyTurnBridge(t, chat)
|
||
b.bubbleGapFn = func() time.Duration { return 0 }
|
||
|
||
b.process(context.Background(), directMessage())
|
||
|
||
// 落库(Web 镜像):两段 <p>,中间保留空行。
|
||
assertAssistantReply(t, hist, "<p>甲</p>\n\n<p>乙</p>")
|
||
if len(ad.sends) != 2 {
|
||
t.Fatalf("出站气泡 = %d 条, want 2(`\\n\\n` ⇒ 空行 ⇒ 段落分隔): %+v", len(ad.sends), ad.sends)
|
||
}
|
||
if got, want := ad.sends[0].Content, "甲"; got != want {
|
||
t.Fatalf("气泡[0] = %q, want %q", got, want)
|
||
}
|
||
if got, want := ad.sends[1].Content, "乙"; got != want {
|
||
t.Fatalf("气泡[1] = %q, want %q", got, want)
|
||
}
|
||
}
|