package channelbridge
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件锁定「助手回复落库/出站前做段落包裹规整」这条写入侧修复(IM 一侧)。
//
// 同一根缺陷在 IM 侧有两个出口,本文件两条都钉住:
// - **落库**:主人私聊回复会镜像进 Web 主会话,半包文本落到 Web 历史里同样会
// 被客户端渲染成一整坨;
// - **出站**:splitBubbles 的判据与客户端**逐字相同**(只要存在完整
块就
// 只切
块),半包文本在 IM 侧同样只会得到一个气泡、前面几段凭空消失。
//
// 两侧共用同一份样本字面量(网关一侧见 gateway/session_turn_reply_paragraph_test.go,
// 两处的 halfWrappedTurnReply / wrappedTurnReply 必须逐字相同 —— 那正是「Web 与 IM
// 吃到同一份文本」的断言方式)。
// halfWrappedTurnReply 是上报的半包形状:4 段裸文本(第 4 段内联一个 )+
// 只有第 5 段带 。
const halfWrappedTurnReply = "第一段,读到头也读到尾啦♪\n\n" +
"第二段,轻轻合上纸条。\n\n" +
"第三段,纸张有点卷边了。\n\n" +
"把整卷纸条摊在小桌上第四段,你看这里还有个小记号。\n\n" +
"
第五段,我把它们按日子排好啦。
"
// wrappedTurnReply 是 halfWrappedTurnReply 规整后的规范形态(每段一个 )。
const wrappedTurnReply = "
第一段,读到头也读到尾啦♪
\n\n" +
"第二段,轻轻合上纸条。
\n\n" +
"第三段,纸张有点卷边了。
\n\n" +
"把整卷纸条摊在小桌上第四段,你看这里还有个小记号。
\n\n" +
"第五段,我把它们按日子排好啦。
"
// TestTurnReply_HalfWrappedReplyRegularizedForBothSides 是必测①/⑤(IM 侧):半包
// 回复经真流水线后,**落库文本**与**出站气泡**都基于同一份规整文本 —— 落库逐字
// 等于网关侧断言的那份规范文本,出站是 5 个气泡(而不是过去那 1 个),
// 按渠道既有行为被剥掉。
func TestTurnReply_HalfWrappedReplyRegularizedForBothSides(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: halfWrappedTurnReply, Mood: "平静"}}
b, ad, hist := replyTurnBridge(t, chat)
// 5 个气泡 ⇒ 4 次气泡间延时;测试里归零,否则真的会 sleep 0.5-2s。
b.bubbleGapFn = func() time.Duration { return 0 }
b.process(context.Background(), directMessage())
// 落库(Web 主会话镜像)吃的是规整后的同一份文本。
rec := assertAssistantReply(t, hist, wrappedTurnReply)
if rec.Mood != "平静" {
t.Fatalf("assistant mood = %q, want 平静", rec.Mood)
}
// 出站:不再塌成一条 —— 每段一个气泡。
want := []string{
"第一段,读到头也读到尾啦♪",
"第二段,轻轻合上纸条。",
"第三段,纸张有点卷边了。",
"第四段,你看这里还有个小记号。",
"第五段,我把它们按日子排好啦。",
}
if len(ad.sends) != len(want) {
t.Fatalf("出站气泡 = %d 条, want %d(半包曾整条塌成一个气泡): %+v", len(ad.sends), len(want), ad.sends)
}
for i, w := range want {
if got := ad.sends[i].Content; got != w {
t.Fatalf("气泡[%d] = %q, want %q", i, got, w)
}
}
}
// TestTurnReply_CanonicalReplyStaysByteIdentical 是必测②(IM 侧):已规范的回复
// 落库与出站都不被二次加工 —— 落库逐字不变。
func TestTurnReply_CanonicalReplyStaysByteIdentical(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: wrappedTurnReply}}
b, ad, hist := replyTurnBridge(t, chat)
b.bubbleGapFn = func() time.Duration { return 0 }
b.process(context.Background(), directMessage())
assertAssistantReply(t, hist, wrappedTurnReply)
if len(ad.sends) != 5 {
t.Fatalf("出站气泡 = %d 条, want 5", len(ad.sends))
}
}
// TestTurnReply_FullyBareReplyIsRegularized 是必测③(IM 侧):完全没有任何 的
// 裸文本也会被规整后交付 —— 过去它拿到的是「一个含换行的气泡」,现在是三段气泡。
func TestTurnReply_FullyBareReplyIsRegularized(t *testing.T) {
t.Parallel()
const bare = "第一段。\n\n第二段。\n\n拍拍你"
const want = "
第一段。
\n\n第二段。
\n\n拍拍你
"
chat := &stubChatter{res: &loop.Result{Text: bare}}
b, ad, hist := replyTurnBridge(t, chat)
b.bubbleGapFn = func() time.Duration { return 0 }
b.process(context.Background(), directMessage())
assertAssistantReply(t, hist, want)
// 纯动作段被剥成空 ⇒ 只发两个气泡(既有行为,不受本改动影响)。
if len(ad.sends) != 2 {
t.Fatalf("出站气泡 = %d 条, want 2: %+v", len(ad.sends), ad.sends)
}
if ad.sends[0].Content != "第一段。" || ad.sends[1].Content != "第二段。" {
t.Fatalf("气泡 = %+v, want [第一段。 第二段。]", ad.sends)
}
}
// TestTurnReply_BlankReplyStaysUntouchedByRegularization 是必测④(IM 侧):空回复
// 走的是另一条分支(补诚实说明),规整不介入 —— 文案与共享包逐字相同。
func TestTurnReply_BlankReplyStaysUntouchedByRegularization(t *testing.T) {
t.Parallel()
for _, blank := range []string{"", " "} {
chat := &stubChatter{res: &loop.Result{Text: blank}}
b, ad, hist := replyTurnBridge(t, chat)
b.process(context.Background(), directMessage())
if len(ad.sends) != 1 {
t.Fatalf("出站消息 = %d 条, want 1(空回复也要有可见回复)", len(ad.sends))
}
if got := ad.sends[0].Content; strings.Contains(got, "") {
t.Fatalf("兜底文案被包上了段落标记: %q", got)
}
assertAssistantReply(t, hist, turnreply.FallbackEmptyReply)
}
}
// TestSendProactive_HalfWrappedPushRegularized 覆盖**第三个**助手文本产出点:主动
// 推送(backthink)。它与回合回复同一类缺陷、同一份判据,因此走同一份规整 ——
// IM 侧不再塌成一个气泡,镜像进 Web 主会话的也是规整后的同一份文本。
func TestSendProactive_HalfWrappedPushRegularized(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 }
if err := b.SendProactive(context.Background(), proactiveMsg(halfWrappedTurnReply)); err != nil {
t.Fatalf("SendProactive: %v", err)
}
if len(weixin.sends) != 5 {
t.Fatalf("weixin 气泡 = %d 条, want 5(半包推送曾整条塌成一个气泡): %+v", len(weixin.sends), weixin.sends)
}
recs := hist.records()
if len(recs) != 1 {
t.Fatalf("镜像进主会话 = %d 条, want 1: %+v", len(recs), recs)
}
if recs[0].Content != wrappedTurnReply {
t.Fatalf("镜像内容 =\n%q\nwant 与 IM 出站同一份(网关侧同一字面量)\n%q", recs[0].Content, wrappedTurnReply)
}
}