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. 🔴 **`
` 仍是气泡分隔符,字面 `\n` 只做气泡内换行** —— 把字面 `\n` 升级成
// 气泡切分会把一条列表拆成七条消息,比原缺陷更吵。
//
// 样本是**真机原文**(主人 2026-10-24 报障那条乐理回复,库里的 assistant 行,
// 6 处字面 `\n` 全挤在同一个
里)。 // literalEscape 是模型写出、而清洗链不认的那两个字符:反斜杠 + n。 const literalEscape = `\n` // realLiteralBreakReply 是上报的原文,逐字取自 chat_messages(455 字符 / 907 字节、 // 5 个
、6 处字面 `\n`、不含反引号)。断言依赖这几条性质,改样本时必须同步。 const realLiteralBreakReply = `
伊奥尼亚: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♭
那些重降的等音:A♭♭=G、B♭♭=A、D♭♭=C、E♭♭=D。
不过要是题目问的是「G♭ 大调关系调式」那种,七种用的都是 G♭ A♭ B♭ C♭ D♭ E♭ F 这一组音,只是主音各站各的位置♪
` // TestTurnReply_LiteralBreakRenderedForBothSides 是必测①(真机原文):修复前这条 // 回复的第三个是一行字面 `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 个
(字面 `\n` 绝不升级成段落或
)。
if got := strings.Count(rec.Content, "
"); got != 5 { t.Fatalf("落库文本
块 = %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 条气泡(
一级、
二级的既有语义不变),第三条是七行。
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, "
—— 字面 `\\n` 只应做气泡内换行: %q", i, sent.Content)
}
}
// ③ 同源:出站气泡就是落库镜像里那一段逐字(Web 与 IM 没有第二份加工)。
if !strings.Contains(rec.Content, scale) {
t.Fatalf("出站气泡不在落库镜像里 ⇒ 两端不是同一份文本:\n气泡 = %q\n落库 = %q", scale, rec.Content)
}
}
// TestTurnReply_LiteralBreakKeepsBreakTagBubbles 是必测⑤(既有语义不变):`
` 仍
// 是**气泡分隔符**(两条消息),而字面 `\n` 只在每个气泡内部换行 —— 二级切分的
// 判据一个字节都没动。
func TestTurnReply_LiteralBreakKeepsBreakTagBubbles(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())
assertAssistantReply(t, hist, "
甲\n乙
丙\n丁
第一行\n第二行
"; got != want { t.Fatalf("镜像内容 = %q, want %q(与出站同一份)", got, want) } } // TestTurnReply_DoubleLiteralBreakBecomesParagraphBreak 钉住一个**口径边界**(复审观察 // ②):两个连续的字面 `\n` 渲染后就是一个空行,而空行在写入侧规整里是**段落分隔** ⇒ // 两段 `` ⇒ 两端各两条气泡。 // // 语义上说得通(空行 = 段落),但它与「字面 `\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 镜像):两段
,中间保留空行。 assertAssistantReply(t, hist, "
甲
\n\n乙
") 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) } }