Files
Cyrene_v2/internal/services/channelbridge/sanitize_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

179 lines
8.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 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)
}
}