Files
AskaEth 1d3965aa80 fix(gateway): Web 活跃客户端主动推送走 turnreply 规整(先规整再落库,推帧与落库逐字相同)
M19 复审抓到的真缺口:internal/services/gateway/proactive.go 的
SendProactive -> persistProactiveToMain 这条 Web 活跃客户端主动推送路由
完全绕过 turnreply.NormalizeParagraphs —— 同一条主动消息走 IM 是规整过的、
走 Web 实时推还是原样(半包 <p> 在客户端按「存在完整 <p> 就不再按空行回退」
的契约下整条塌成一坨)。

- 接线:SendProactive 在落库之前先 msg.Content = turnreply.NormalizeParagraphs(msg.Content),
  落库与 proactive_message 帧取同一个字符串 ⇒ 两端逐字相同;不在 gateway 另写实现。
- 新增 proactive_normalize_test.go:修前红(web 原样 / im 已规整),修后断言
  ① 落库是规整后文本 ② 推帧 == 落库行 ③ 两条路由同源同文本 ④ <br>/<action> 语义不动。
- 既有两条断言按新口径更新(纯散文现在也被包成一个 <p>,与其它三处一致)。
2026-10-24 19:40:30 +08:00

147 lines
6.1 KiB
Go
Raw Permalink 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 gateway
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
)
// 本文件锁定 M19 复审抓到的真缺口(切片 M25):**Web 活跃客户端**这条主动推送
// 路由过去把 msg.Content 原样落库、原样推帧,**完全绕过**
// `turnreply.NormalizeParagraphs` —— 于是同一条主动消息走 IM 是规整过的
// (channelbridge/proactive.go 出口),走 Web 实时推还是原样:半包 `<p>`
// (只有末段被包)在客户端「只要存在完整 <p> 块就不再按空行回退切分」的契约下
// 整条塌成一坨、而不是多个气泡。
//
// 三条断言缺一不可:
// - 落库行吃的是**规整后**的文本(顺序:先规整、再落库 —— 否则库里还是脏的);
// - 推帧文本与落库行**逐字相同**(只断言「各自含换行」会漏掉两侧分叉);
// - 规整确实发生了(原文 ≠ 期望值),否则断言会退化成同义反复。
// proactiveHalfWrapped 是缺陷形状的输入:第一段裸文本用空行分隔,末段已包 `<p>`。
// 它同时带**字面换行**(`\n`)与半包段落 —— 正是主人 2026-10-24 报的那两类。
const proactiveHalfWrapped = "今晚早点睡。\n\n<p>明天还有早会。</p>"
// proactiveHalfWrappedFixed 是同一段文本经共享规整器后的规范形态:每段一个 <p>,
// 段间空行原样保留(客户端因此拆出两个气泡)。
const proactiveHalfWrappedFixed = "<p>今晚早点睡。</p>\n\n<p>明天还有早会。</p>"
// TestHub_SendProactive_NormalizesParagraphsAndFrameMatchesRow 是 M25 的主断言。
func TestHub_SendProactive_NormalizesParagraphsAndFrameMatchesRow(t *testing.T) {
t.Parallel()
store := newHistoryStoreForGateway(t)
hb := newTestHub(nil, nil)
hb.history = store
owner := newOwnerConn(hb, 8)
hb.register(owner)
if _, err := hb.SendProactive(context.Background(), proactiveStoreMsg(proactiveHalfWrapped)); err != nil {
t.Fatalf("SendProactive err = %v", err)
}
rows := mainSessionRows(t, store)
if len(rows) != 1 {
t.Fatalf("主会话里应恰好一条主动消息,得到 %d 条", len(rows))
}
row := rows[0]
if row.Content == proactiveHalfWrapped {
t.Fatalf("落库文本仍与原文逐字相同 ⇒ 规整没生效(本路由绕过了 NormalizeParagraphs)")
}
if row.Content != proactiveHalfWrappedFixed {
t.Fatalf("落库文本 =\n%q\nwant\n%q\n(Web 活跃路由必须与其它三处同源:先规整再落库)",
row.Content, proactiveHalfWrappedFixed)
}
frame := readFakeFrame(t, owner)
if frame.Type != wsProactiveMessage {
t.Fatalf("frame type = %q, want %q", frame.Type, wsProactiveMessage)
}
data, ok := frame.Data.(map[string]any)
if !ok {
t.Fatalf("frame data type = %T, want map[string]any", frame.Data)
}
content, _ := data["content"].(string)
if content != row.Content {
t.Fatalf("推帧文本与落库行必须逐字相同:\nframe=%q\nrow =%q", content, row.Content)
}
}
// TestHub_SendProactive_KeepsExistingRenderSemantics 钉住「不许改语义」:规整只做
// 段落包裹,`<br>` 仍是气泡内分隔(由客户端按既有契约渲染)、`<action>` 仍随所在
// 段落进**同一个** `<p>`(Web 灰斜体、IM 出口剥除,两条都不许被拆散)。
func TestHub_SendProactive_KeepsExistingRenderSemantics(t *testing.T) {
t.Parallel()
store := newHistoryStoreForGateway(t)
hb := newTestHub(nil, nil)
hb.history = store
owner := newOwnerConn(hb, 8)
hb.register(owner)
raw := "先点点头。<br>再说一句。\n\n<action>把杯子放下</action>"
if _, err := hb.SendProactive(context.Background(), proactiveStoreMsg(raw)); err != nil {
t.Fatalf("SendProactive err = %v", err)
}
rows := mainSessionRows(t, store)
if len(rows) != 1 {
t.Fatalf("主会话里应恰好一条主动消息,得到 %d 条", len(rows))
}
row := rows[0]
want := turnreply.NormalizeParagraphs(raw)
if row.Content != want {
t.Fatalf("落库文本 =\n%q\nwant\n%q", row.Content, want)
}
if !strings.Contains(row.Content, "<br>") {
t.Fatalf("文本里的 <br> 被改掉了:%q(它仍是气泡内分隔,不许被吞)", row.Content)
}
if !strings.Contains(row.Content, "<p><action>把杯子放下</action></p>") {
t.Fatalf("action 段必须随所在段落进同一个 <p>:%q", row.Content)
}
frame := readFakeFrame(t, owner)
data, ok := frame.Data.(map[string]any)
if !ok {
t.Fatalf("frame data type = %T, want map[string]any", frame.Data)
}
if content, _ := data["content"].(string); content != row.Content {
t.Fatalf("推帧文本与落库行必须逐字相同:\nframe=%q\nrow =%q", content, row.Content)
}
}
// TestProactiveRoutes_NormalizeSameText 是「同源」的可执行证据:同一条主动消息分别
// 走 Web 活跃路由与 IM 桥路由,两侧落库文本必须逐字相同 —— 两条路由各写一份规整
// 实现就迟早漂移,本仓已因此栽过多次。
func TestProactiveRoutes_NormalizeSameText(t *testing.T) {
t.Parallel()
store := newHistoryStoreForGateway(t)
hb := newTestHub(nil, nil)
hb.history = store
hb.register(newOwnerConn(hb, 8))
bridge, _ := newProactiveTestBridge(t, store, nil)
msg := proactiveStoreMsg(proactiveHalfWrapped)
if _, err := hb.SendProactive(context.Background(), msg); err != nil {
t.Fatalf("active route err = %v", err)
}
if rows := mainSessionRows(t, store); len(rows) != 1 {
t.Fatalf("活跃路由之后主会话有 %d 条,want 1", len(rows))
}
if err := bridge.SendProactive(context.Background(), msg); err != nil {
t.Fatalf("im route err = %v", err)
}
rows := mainSessionRows(t, store)
if len(rows) != 2 {
t.Fatalf("两条路由各跑一次后主会话有 %d 条,want 2", len(rows))
}
// rows 最新优先:第一条是 IM 路由刚写的,第二条是活跃路由写的。
if rows[0].Content != rows[1].Content {
t.Fatalf("两条路由的落库文本必须逐字相同(同一份规整):\nim =%q\nweb =%q",
rows[0].Content, rows[1].Content)
}
if rows[0].Content != proactiveHalfWrappedFixed {
t.Fatalf("两条路由的文本 =\n%q\nwant\n%q", rows[0].Content, proactiveHalfWrappedFixed)
}
}