test(channelbridge): 握手回放也断言出站 voice(M58)

- dialOwnerWSHistory 交回 history 帧,replayVoiceOfID 按 id 取 voice 原文
- 成功与降级两条整链用例都补「重连 WS 回放」断言,与实时帧逐字相等
This commit is contained in:
AskaEth committed 2026-10-27 22:08:38 +08:00
1 parent 28afa11aeb
commit 8ba4cde61d
1 file changed
+56 -5
@@ -11,9 +11,10 @@ package channelbridge
// 而本切片要钉的正是「结论已经在手里之后」那一段。 // 而本切片要钉的正是「结论已经在手里之后」那一段。
// //
// 钉住四件事: // 钉住四件事:
// 1. 合成成功 ⇒ 助手回复的**第一帧**就带 `voice`(source=tts/status=transcribed + 副本四键); // 1. 合成成功 ⇒ 助手回复的**第一帧**就带 `voice`(source=tts/status=transcribed + 副本四键),
// 2. 合成降级 ⇒ `voice.status=failed` + `fallbackReason` 在帧上**与**历史回看(REST)上都可见, // 重新连 WS 的握手回放与 REST 读面逐字相同;
// 且正文照发原文(降级不是「少说一句话」); // 2. 合成降级 ⇒ `voice.status=failed` + `fallbackReason` 在帧上**与**历史回看(REST、握手回放)
// 上都可见,且正文照发原文(降级不是「少说一句话」);
// 3. 音频只挂在**第一个**气泡上(多段回复里重复挂会让对方收到 N 份同样的语音); // 3. 音频只挂在**第一个**气泡上(多段回复里重复挂会让对方收到 N 份同样的语音);
// 4. 出站 Media 的字节就是盘上那份副本(读不回来时宁可不挂,见 outbound_voice.go)。 // 4. 出站 Media 的字节就是盘上那份副本(读不回来时宁可不挂,见 outbound_voice.go)。
@@ -118,6 +119,14 @@ func newOutboundVoiceE2E(t *testing.T, sess session.Session) *outboundVoiceE2E {
// dialOwnerWS 连上真网关的 `/ws/chat`(owner token),并吃掉握手回放的 `history` 帧, // dialOwnerWS 连上真网关的 `/ws/chat`(owner token),并吃掉握手回放的 `history` 帧,
// 使后续读取从「本回合的第一帧」开始(与入站整链同一手法)。 // 使后续读取从「本回合的第一帧」开始(与入站整链同一手法)。
func (e *outboundVoiceE2E) dialOwnerWS(t *testing.T) *websocket.Conn { func (e *outboundVoiceE2E) dialOwnerWS(t *testing.T) *websocket.Conn {
t.Helper()
conn, _ := e.dialOwnerWSHistory(t)
return conn
}
// dialOwnerWSHistory 同 dialOwnerWS,但把握手回放的 `history` 帧一并交回 —— 出站语音
// 也得在「隔天重新打开客户端」的那条路上看得见(列有值即带),这条读面因此要能被断言。
func (e *outboundVoiceE2E) dialOwnerWSHistory(t *testing.T) (*websocket.Conn, wsFrame) {
t.Helper() t.Helper()
wsURL := "ws" + strings.TrimPrefix(e.ts.URL, "http") + "/ws/chat?token=" + e.tok wsURL := "ws" + strings.TrimPrefix(e.ts.URL, "http") + "/ws/chat?token=" + e.tok
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
@@ -129,7 +138,30 @@ func (e *outboundVoiceE2E) dialOwnerWS(t *testing.T) *websocket.Conn {
if ev.Type != "history" { if ev.Type != "history" {
t.Fatalf("第一个帧 = %q, want history(握手回放)", ev.Type) t.Fatalf("第一个帧 = %q, want history(握手回放)", ev.Type)
} }
return conn return conn, ev
}
// replayVoiceOfID 从握手回放帧的 `data.messages` 里按 id 找到那一行,返回它的 `voice`
// 原始 JSON(键的在场性因此可判);该行没有 `voice` 键时返回 nil 与 false。
func replayVoiceOfID(t *testing.T, ev wsFrame, id string) (json.RawMessage, bool) {
t.Helper()
var data struct {
Messages []map[string]json.RawMessage `json:"messages"`
}
if err := json.Unmarshal(ev.Data, &data); err != nil {
t.Fatalf("解码握手回放帧 %s: %v", ev.Data, err)
}
for _, row := range data.Messages {
var gotID string
_ = json.Unmarshal(row["id"], &gotID)
if gotID != id {
continue
}
raw, ok := row["voice"]
return raw, ok
}
t.Fatalf("握手回放里没有 id=%q 的那一行(回放=%s)", id, ev.Data)
return nil, false
} }
// storeOutboundCopy 用**真**音频落盘分支(`documents.Store.SaveAudio`)把一份合成产物 // storeOutboundCopy 用**真**音频落盘分支(`documents.Store.SaveAudio`)把一份合成产物
@@ -218,6 +250,15 @@ func TestIMOutboundVoiceFirstFrameCarriesVoice(t *testing.T) {
if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want { if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want {
t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want) t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want)
} }
// 握手回放(隔天重新打开客户端):同一条消息在 `history` 帧里也带着这份出站语音。
_, replay := env.dialOwnerWSHistory(t)
replayRaw, ok := replayVoiceOfID(t, replay, id)
if !ok {
t.Fatalf("🔴 握手回放里这条出站语音缺 voice 键: %s", replay.Data)
}
if got, want := canonicalVoice(t, replayRaw), canonicalVoice(t, raw); got != want {
t.Fatalf("握手回放里的 voice = %s, 实时帧 = %s", got, want)
}
// 正文一字未改:语音是这条回复的另一种呈现,不是它的替代品。 // 正文一字未改:语音是这条回复的另一种呈现,不是它的替代品。
if got := frameContent(t, msg); got != outboundVoiceStoredText { if got := frameContent(t, msg); got != outboundVoiceStoredText {
t.Fatalf("帧里的正文 = %q, want %q", got, outboundVoiceStoredText) t.Fatalf("帧里的正文 = %q, want %q", got, outboundVoiceStoredText)
@@ -258,10 +299,20 @@ func TestIMOutboundVoiceFallbackIsVisibleAsFailed(t *testing.T) {
} }
} }
// 历史回看(REST 读面)与帧上是同一份事实 —— 隔天再打开也看得到这条降级。 // 历史回看(REST 读面)与帧上是同一份事实 —— 隔天再打开也看得到这条降级。
restVoice := voiceOfRowByID(t, fetchMessages(t, env.ts.URL, history.MainSessionID, env.tok), messageIDOf(t, msg)) id := messageIDOf(t, msg)
restVoice := voiceOfRowByID(t, fetchMessages(t, env.ts.URL, history.MainSessionID, env.tok), id)
if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want { if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want {
t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want) t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want)
} }
// 握手回放(重新连 WS)同样带着这条降级:客户端的「本来想用语音」小标不只在实时帧上有。
_, replay := env.dialOwnerWSHistory(t)
replayRaw, ok := replayVoiceOfID(t, replay, id)
if !ok {
t.Fatalf("🔴 握手回放里这条降级回复缺 voice 键: %s", replay.Data)
}
if got, want := canonicalVoice(t, replayRaw), canonicalVoice(t, raw); got != want {
t.Fatalf("握手回放里的 voice = %s, 实时帧 = %s", got, want)
}
// 正文原样:降级只影响「有没有音频」,绝不影响说什么。 // 正文原样:降级只影响「有没有音频」,绝不影响说什么。
if got := frameContent(t, msg); got != outboundVoiceStoredText { if got := frameContent(t, msg); got != outboundVoiceStoredText {
t.Fatalf("降级回复的正文 = %q, want %q(正文不该被降级改写)", got, outboundVoiceStoredText) t.Fatalf("降级回复的正文 = %q, want %q(正文不该被降级改写)", got, outboundVoiceStoredText)