- dialOwnerWSHistory 交回 history 帧,replayVoiceOfID 按 id 取 voice 原文 - 成功与降级两条整链用例都补「重连 WS 回放」断言,与实时帧逐字相等
490 lines
21 KiB
Go
490 lines
21 KiB
Go
package channelbridge
|
||
|
||
// 本文件是 M58 的**跨层整链**验收:出站语音结论(`loop.Result.Voice`,M57 的 speak 工具
|
||
// 结算)→ 桥接器落库并写 `chat_messages.voice` 列 → 真网关的**实时帧** + REST 读面;
|
||
// 以及同一回合的出站 `adapter.Media` 挂载。
|
||
//
|
||
// 手法与入站整链(inbound_voice_gateway_e2e_test.go)同源:**同一个**真 `history.Store`
|
||
// 喂给桥接器与网关,断言因此打在真 WS 帧与真 REST 读面上,而不是两个替身各自的想象。
|
||
// 唯一的差别是驱动方式:出站结论来自循环,所以这里用 `stubChatter` 直接给出带 `Voice`
|
||
// 的 `Result` —— 真循环 + 假模型跑不出 `Result.Voice`(假模型不会去调 speak 工具),
|
||
// 而本切片要钉的正是「结论已经在手里之后」那一段。
|
||
//
|
||
// 钉住四件事:
|
||
// 1. 合成成功 ⇒ 助手回复的**第一帧**就带 `voice`(source=tts/status=transcribed + 副本四键),
|
||
// 重新连 WS 的握手回放与 REST 读面逐字相同;
|
||
// 2. 合成降级 ⇒ `voice.status=failed` + `fallbackReason` 在帧上**与**历史回看(REST、握手回放)
|
||
// 上都可见,且正文照发原文(降级不是「少说一句话」);
|
||
// 3. 音频只挂在**第一个**气泡上(多段回复里重复挂会让对方收到 N 份同样的语音);
|
||
// 4. 出站 Media 的字节就是盘上那份副本(读不回来时宁可不挂,见 outbound_voice.go)。
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gorilla/websocket"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||
)
|
||
|
||
const (
|
||
// outboundVoiceText 是所有出站用例的回复正文(单段,便于把「正文原样」写成字面量)。
|
||
outboundVoiceText = "晚安啦,做个好梦。"
|
||
// outboundVoiceStoredText 是它经回合可见回复规整(turnreply.NormalizeParagraphs)后
|
||
// **真正落库、真正上帧**的文本:单段被包进一对 `<p>`。降级用例按这个字面量比对,
|
||
// 因此「合成失败有没有动正文」是一个逐字问题,而不是「大概还在」。
|
||
outboundVoiceStoredText = "<p>晚安啦,做个好梦。</p>"
|
||
// outboundVoiceMultiText 是两段回复:出站音频只该跟着第一段走。
|
||
outboundVoiceMultiText = "第一段话,先说给你听。\n\n第二段话,说给明天听。"
|
||
// outboundVoiceAudioMIME 是 `.ogg` 副本的下发类型(voice/media.go 的成对裁决产物)。
|
||
outboundVoiceAudioMIME = "audio/ogg"
|
||
)
|
||
|
||
// outboundVoiceE2E 打包出站语音整链环境:真桥接器(可注入 `Result.Voice` 的循环替身 +
|
||
// 真历史存储 + 真音频落盘配置)+ 真网关(同一个历史存储)+ 组合根的持久化钩子。
|
||
type outboundVoiceE2E struct {
|
||
bridge *Bridge
|
||
chat *stubChatter
|
||
platform *fakeAdapter
|
||
// uploads 是桥接器会去读副本的那个目录(`documents.Store.Dir()`)。
|
||
uploads string
|
||
ts *httptest.Server
|
||
tok string
|
||
}
|
||
|
||
// newOutboundVoiceE2E 装配整链环境。历史存储是**同一个句柄**喂给桥接器与网关 ——
|
||
// 被验证的正是这条边界:桥接器写进 `voice` 列的事实,就是实时帧与 REST 读面投影出来的事实。
|
||
func newOutboundVoiceE2E(t *testing.T, sess session.Session) *outboundVoiceE2E {
|
||
t.Helper()
|
||
dataDir := t.TempDir()
|
||
uploads := filepath.Join(dataDir, "uploads", "documents")
|
||
log := slog.New(slog.NewTextHandler(&safeBuffer{}, nil))
|
||
st := openGatewayDocStore(t, log)
|
||
|
||
chat := &stubChatter{res: &loop.Result{Text: outboundVoiceText}}
|
||
ad := &fakeAdapter{name: "telegram"}
|
||
b, err := New(Options{
|
||
Log: log,
|
||
Loop: chat,
|
||
Sessions: &stubSessions{sess: sess},
|
||
Owner: testOwnerProfile("telegram", "100"),
|
||
History: st,
|
||
// 真音频落盘配置:副本由 documents.Store.SaveAudio 落下,出站时按同一目录读回。
|
||
DocumentStore: documents.StoreConfig{DataDir: dataDir, UploadsDir: uploads},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("构造桥接器: %v", err)
|
||
}
|
||
// 气泡间隔在生产里是 0.5-2s:多段用例不该为它付真实时间。
|
||
b.bubbleGapFn = func() time.Duration { return 0 }
|
||
b.Add(ad)
|
||
|
||
jwt := auth.New(config.AuthConfig{JWTSecret: gatewayDocSecret})
|
||
srv, err := gateway.New(
|
||
&config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: gatewayDocSecret}},
|
||
gateway.Deps{Auth: jwt, History: st, DataDir: dataDir},
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("构造网关: %v", err)
|
||
}
|
||
t.Cleanup(srv.Close)
|
||
ts := httptest.NewServer(srv.Handler())
|
||
t.Cleanup(ts.Close)
|
||
// 🔴 组合根的同一接线(cmd/cyrene/wire_frontend.go:SetOnPersisted(gw.BroadcastPersisted))。
|
||
b.SetOnPersisted(srv.BroadcastPersisted)
|
||
|
||
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
||
if err != nil {
|
||
t.Fatalf("签发 token: %v", err)
|
||
}
|
||
return &outboundVoiceE2E{bridge: b, chat: chat, platform: ad, uploads: uploads, ts: ts, tok: tok}
|
||
}
|
||
|
||
// dialOwnerWS 连上真网关的 `/ws/chat`(owner token),并吃掉握手回放的 `history` 帧,
|
||
// 使后续读取从「本回合的第一帧」开始(与入站整链同一手法)。
|
||
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()
|
||
wsURL := "ws" + strings.TrimPrefix(e.ts.URL, "http") + "/ws/chat?token=" + e.tok
|
||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||
if err != nil {
|
||
t.Fatalf("连接 WS: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = conn.Close() })
|
||
ev := readWSFrame(t, conn)
|
||
if ev.Type != "history" {
|
||
t.Fatalf("第一个帧 = %q, want history(握手回放)", ev.Type)
|
||
}
|
||
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`)把一份合成产物
|
||
// 落到桥接器会去读的那个目录里,返回它签发的引用。名字/类型/大小都按生产口径算出来 ——
|
||
// 绝不手拼一个「看起来像」的文件名,否则本文件测的就不是那条读回路径。
|
||
func storeOutboundCopy(t *testing.T, uploads string, data []byte) documents.Ref {
|
||
t.Helper()
|
||
store := documents.NewStore(documents.StoreConfig{UploadsDir: uploads})
|
||
ref, err := store.SaveAudio(context.Background(), "voice.ogg", data)
|
||
if err != nil {
|
||
t.Fatalf("落盘出站语音副本: %v", err)
|
||
}
|
||
if ref.Name == "" || ref.MIME != outboundVoiceAudioMIME || ref.Size != int64(len(data)) {
|
||
t.Fatalf("落盘引用 = %+v, want 名字/%s/%d 字节", ref, outboundVoiceAudioMIME, len(data))
|
||
}
|
||
return ref
|
||
}
|
||
|
||
// nextPersistedFrameWithRole 读到**下一条指定 role 的持久化帧**(跳过其余帧)。出站用例
|
||
// 要的是**助手**那一行(本回合的用户行帧先到),所以按 role 过滤而不是按先后猜 ——
|
||
// 也因此不需要 sleep 或重试。
|
||
func nextPersistedFrameWithRole(t *testing.T, conn *websocket.Conn, role string) wsFrame {
|
||
t.Helper()
|
||
for i := 0; i < 10; i++ {
|
||
ev := readWSFrame(t, conn)
|
||
switch ev.Type {
|
||
case "message_persisted", "channel_persisted":
|
||
if messageRole(t, persistedMessage(t, ev)) == role {
|
||
return ev
|
||
}
|
||
}
|
||
}
|
||
t.Fatalf("读了 10 帧仍没等到 %s 行的持久化帧", role)
|
||
return wsFrame{}
|
||
}
|
||
|
||
// frameContent 取帧内消息的正文。
|
||
func frameContent(t *testing.T, msg map[string]json.RawMessage) string {
|
||
t.Helper()
|
||
var content string
|
||
if err := json.Unmarshal(msg["content"], &content); err != nil {
|
||
t.Fatalf("解析帧里的 content: %v", err)
|
||
}
|
||
return content
|
||
}
|
||
|
||
// TestIMOutboundVoiceFirstFrameCarriesVoice 钉住出站方向的首帧纪律(M44 的同一条口径):
|
||
// 合成成功的回复,其**第一帧**里就要有 `voice`,且与 REST 读面同一行逐字相等。
|
||
func TestIMOutboundVoiceFirstFrameCarriesVoice(t *testing.T) {
|
||
t.Parallel()
|
||
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
|
||
audio := []byte("OggS-outbound-voice-payload")
|
||
ref := storeOutboundCopy(t, env.uploads, audio)
|
||
env.chat.res = &loop.Result{
|
||
Text: outboundVoiceText,
|
||
Voice: &loop.OutboundVoice{
|
||
Requested: true, Name: ref.Name, Mime: ref.MIME, Size: ref.Size, DurationMs: 1500,
|
||
},
|
||
}
|
||
conn := env.dialOwnerWS(t)
|
||
env.bridge.process(context.Background(), directMessage())
|
||
|
||
ev := nextPersistedFrameWithRole(t, conn, history.RoleAssistant)
|
||
if ev.Type != "message_persisted" {
|
||
t.Fatalf("主人私聊的帧类型 = %q, want message_persisted", ev.Type)
|
||
}
|
||
msg := persistedMessage(t, ev)
|
||
raw, ok := msg["voice"]
|
||
if !ok {
|
||
t.Fatalf("🔴 出站语音的**第一帧**缺 data.message.voice: %s", ev.Data)
|
||
}
|
||
shape := canonicalVoice(t, raw)
|
||
for _, want := range []string{
|
||
`"source":"tts"`, `"status":"transcribed"`,
|
||
`"name":"` + ref.Name + `"`, `"mime":"` + outboundVoiceAudioMIME + `"`,
|
||
fmt.Sprintf(`"size":%d`, ref.Size), `"durationMs":1500`,
|
||
`"url":"/api/v1/media/` + ref.Name + `"`,
|
||
} {
|
||
if !strings.Contains(shape, want) {
|
||
t.Fatalf("帧里的 voice = %s, 缺 %s", shape, want)
|
||
}
|
||
}
|
||
// 与 REST 读面**同一行**逐字相等:同一份事实,不是两处各算。
|
||
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 {
|
||
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 {
|
||
t.Fatalf("帧里的正文 = %q, want %q", got, outboundVoiceStoredText)
|
||
}
|
||
}
|
||
|
||
// TestIMOutboundVoiceFallbackIsVisibleAsFailed 钉住降级的可见性(切片 M58 的第 3 条):
|
||
// `voice.status=failed` + `fallbackReason` 在**实时帧**与**历史回看**上都看得见,正文照发
|
||
// 原文,且渠道侧只收到纯文本气泡(绝不发一个空音频)。
|
||
func TestIMOutboundVoiceFallbackIsVisibleAsFailed(t *testing.T) {
|
||
t.Parallel()
|
||
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
|
||
env.chat.res = &loop.Result{
|
||
Text: outboundVoiceText,
|
||
Voice: &loop.OutboundVoice{Requested: true, FallbackReason: "tts_timeout"},
|
||
}
|
||
conn := env.dialOwnerWS(t)
|
||
env.bridge.process(context.Background(), directMessage())
|
||
|
||
ev := nextPersistedFrameWithRole(t, conn, history.RoleAssistant)
|
||
msg := persistedMessage(t, ev)
|
||
raw, ok := msg["voice"]
|
||
if !ok {
|
||
t.Fatalf("🔴 合成降级的**第一帧**缺 data.message.voice(客户端看不到「本来想用语音」): %s", ev.Data)
|
||
}
|
||
shape := canonicalVoice(t, raw)
|
||
for _, want := range []string{
|
||
`"source":"tts"`, `"status":"failed"`, `"fallbackReason":"tts_timeout"`,
|
||
} {
|
||
if !strings.Contains(shape, want) {
|
||
t.Fatalf("帧里的 voice = %s, 缺 %s", shape, want)
|
||
}
|
||
}
|
||
// 没有副本 ⇒ 副本四键**一起缺席**(不是空串、不是 0):客户端据此不画播放器。
|
||
for _, absent := range []string{`"name"`, `"mime"`, `"size"`, `"url"`} {
|
||
if strings.Contains(shape, absent) {
|
||
t.Fatalf("降级的 voice = %s, 不该有 %s", shape, absent)
|
||
}
|
||
}
|
||
// 历史回看(REST 读面)与帧上是同一份事实 —— 隔天再打开也看得到这条降级。
|
||
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 {
|
||
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 {
|
||
t.Fatalf("降级回复的正文 = %q, want %q(正文不该被降级改写)", got, outboundVoiceStoredText)
|
||
}
|
||
if len(env.platform.sends) != 1 {
|
||
t.Fatalf("渠道侧气泡数 = %d, want 1", len(env.platform.sends))
|
||
}
|
||
sent := env.platform.sends[0]
|
||
if sent.ContentType != adapter.ContentText {
|
||
t.Fatalf("降级气泡的 ContentType = %q, want %q", sent.ContentType, adapter.ContentText)
|
||
}
|
||
if len(sent.Media) != 0 {
|
||
t.Fatalf("降级气泡带上了 Media = %+v, want 空(绝不发空音频)", sent.Media)
|
||
}
|
||
}
|
||
|
||
// TestIMOutboundVoiceMountsMediaOnFirstBubbleOnly 钉住出站 Media:音频只挂**第一个**气泡,
|
||
// 字节就是盘上那份副本,第二个气泡仍是纯文本(同一条合成语音跟着每段各发一次,对方会收到
|
||
// N 份同样的音频)。
|
||
func TestIMOutboundVoiceMountsMediaOnFirstBubbleOnly(t *testing.T) {
|
||
t.Parallel()
|
||
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
|
||
audio := []byte("OggS-multi-paragraph-voice")
|
||
ref := storeOutboundCopy(t, env.uploads, audio)
|
||
env.chat.res = &loop.Result{
|
||
Text: outboundVoiceMultiText,
|
||
Voice: &loop.OutboundVoice{
|
||
Requested: true, Name: ref.Name, Mime: ref.MIME, Size: ref.Size,
|
||
},
|
||
}
|
||
env.bridge.process(context.Background(), directMessage())
|
||
|
||
if len(env.platform.sends) != 2 {
|
||
t.Fatalf("两段回复的气泡数 = %d, want 2(%+v)", len(env.platform.sends), env.platform.sends)
|
||
}
|
||
first := env.platform.sends[0]
|
||
if !strings.Contains(first.Content, "第一段话") {
|
||
t.Fatalf("第一个气泡的正文 = %q, want 第一段", first.Content)
|
||
}
|
||
if first.ContentType != adapter.ContentVoice {
|
||
t.Fatalf("第一个气泡的 ContentType = %q, want %q", first.ContentType, adapter.ContentVoice)
|
||
}
|
||
if len(first.Media) != 1 {
|
||
t.Fatalf("第一个气泡的 Media = %+v, want 恰好一条", first.Media)
|
||
}
|
||
md := first.Media[0]
|
||
if md.Kind != adapter.MediaVoice {
|
||
t.Fatalf("出站媒体 Kind = %q, want %q", md.Kind, adapter.MediaVoice)
|
||
}
|
||
if md.Name != ref.Name || md.MIMEType != outboundVoiceAudioMIME {
|
||
t.Fatalf("出站媒体 = %+v, want name=%s mime=%s", md, ref.Name, outboundVoiceAudioMIME)
|
||
}
|
||
if md.Size != int64(len(audio)) {
|
||
t.Fatalf("出站媒体 Size = %d, want %d", md.Size, len(audio))
|
||
}
|
||
if !bytes.Equal(md.Data, audio) {
|
||
t.Fatalf("出站媒体字节 = %q, want 盘上那份副本 %q", md.Data, audio)
|
||
}
|
||
// URL 刻意留空:`/api/v1/media/` 走 Bearer 鉴权,适配器没有(也不该有)owner 令牌。
|
||
if md.URL != "" {
|
||
t.Fatalf("出站媒体 URL = %q, want 空(适配器只拿字节)", md.URL)
|
||
}
|
||
|
||
second := env.platform.sends[1]
|
||
if !strings.Contains(second.Content, "第二段话") {
|
||
t.Fatalf("第二个气泡的正文 = %q, want 第二段", second.Content)
|
||
}
|
||
if second.ContentType != adapter.ContentText || len(second.Media) != 0 {
|
||
t.Fatalf("第二个气泡 = %+v, want 纯文本且无 Media", second)
|
||
}
|
||
}
|
||
|
||
// TestOutboundVoiceRefShapes 是 outbound_voice.go 的纯函数面:三种结局与「上游契约被破坏」
|
||
// 那一种都必须各归其位 —— 一个没有 mime 的 name 绝不写成 transcribed。
|
||
func TestOutboundVoiceRefShapes(t *testing.T) {
|
||
t.Parallel()
|
||
b, _, _, _ := newTestBridge(t)
|
||
const copyName = "0123456789abcdef0123456789abcdef.ogg"
|
||
|
||
cases := []struct {
|
||
name string
|
||
voice *loop.OutboundVoice
|
||
wantOK bool
|
||
wantRef string
|
||
wantSize int64
|
||
}{
|
||
{name: "nil 结论", voice: nil, wantOK: false},
|
||
{name: "未请求", voice: &loop.OutboundVoice{Requested: false}, wantOK: false},
|
||
{
|
||
name: "合成成功且副本完整",
|
||
voice: &loop.OutboundVoice{Requested: true, Name: copyName, Mime: outboundVoiceAudioMIME, Size: 8192, DurationMs: 1200},
|
||
wantOK: true, wantRef: `{"source":"tts","status":"transcribed","name":"` + copyName + `","mime":"audio/ogg","size":8192,"durationMs":1200}`, wantSize: 8192,
|
||
},
|
||
{
|
||
name: "缺 mime ⇒ 降级 copy_incomplete",
|
||
voice: &loop.OutboundVoice{Requested: true, Name: copyName, Size: 8192},
|
||
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"copy_incomplete"}`, wantSize: 0,
|
||
},
|
||
{
|
||
name: "名字不是内容寻址名 ⇒ 降级 copy_incomplete",
|
||
voice: &loop.OutboundVoice{Requested: true, Name: "../../etc/passwd", Mime: outboundVoiceAudioMIME, Size: 10},
|
||
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"copy_incomplete"}`, wantSize: 0,
|
||
},
|
||
{
|
||
name: "请求了但没有音频 ⇒ 降级原因来自上游",
|
||
voice: &loop.OutboundVoice{Requested: true, FallbackReason: "budget_exceeded"},
|
||
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"budget_exceeded"}`, wantSize: 0,
|
||
},
|
||
{name: "请求了但既无副本又无原因", voice: &loop.OutboundVoice{Requested: true, CostMs: 90}, wantOK: false},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
ref, ok := b.outboundVoiceRef(tc.voice)
|
||
if ok != tc.wantOK {
|
||
t.Fatalf("写列 = %v, want %v(ref=%+v)", ok, tc.wantOK, ref)
|
||
}
|
||
if !ok {
|
||
return
|
||
}
|
||
raw, err := voiceref.Encode(ref)
|
||
if err != nil {
|
||
t.Fatalf("编码 voice ref: %v", err)
|
||
}
|
||
if raw != tc.wantRef {
|
||
t.Fatalf("voice ref = %s, want %s", raw, tc.wantRef)
|
||
}
|
||
if ref.Size != tc.wantSize {
|
||
t.Fatalf("ref.Size = %d, want %d", ref.Size, tc.wantSize)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// voiceRefWithCopy 造一条「盘上应当有一份副本」的 voice 事实。字段按生产口径配齐
|
||
// (name/mime/size 同生共死,否则 Validate 会拒绝)—— 名字交给调用方给,本函数不猜。
|
||
func voiceRefWithCopy(name string, size int64) voiceref.Ref {
|
||
return voiceref.Ref{
|
||
Source: voiceref.SourceTTS, Status: voiceref.StatusTranscribed,
|
||
Name: name, MIME: outboundVoiceAudioMIME, Size: size,
|
||
}
|
||
}
|
||
|
||
// TestOutboundVoiceMediaSkipsUnreadableCopy 钉住「拿不到字节就不挂音频」:附件存储未接线、
|
||
// 或副本读不回来时,出站媒体一律不挂(正文照发文本,WARN 由 outbound_voice.go 记下)。
|
||
func TestOutboundVoiceMediaSkipsUnreadableCopy(t *testing.T) {
|
||
t.Parallel()
|
||
const copyName = "0123456789abcdef0123456789abcdef.ogg"
|
||
ref := voiceRefWithCopy(copyName, 1)
|
||
|
||
// ① 附件存储未接线(UploadsDir 与 DataDir 都空)⇒ inboundDocumentStore 返回 nil。
|
||
bare, _, _, _ := newTestBridge(t)
|
||
if md, ok := bare.outboundVoiceMedia(directMessage(), ref); ok {
|
||
t.Fatalf("存储未接线却挂上了出站媒体 = %+v", md)
|
||
}
|
||
|
||
// ② 目录配好了,但盘上没有这份副本 ⇒ 读不回来。
|
||
empty := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
|
||
if md, ok := empty.bridge.outboundVoiceMedia(directMessage(), ref); ok {
|
||
t.Fatalf("副本不存在却挂上了出站媒体 = %+v", md)
|
||
}
|
||
|
||
// ③ 副本真在盘上 ⇒ 挂得上,且字节逐字相等。
|
||
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
|
||
audio := []byte("OggS-readback")
|
||
stored := storeOutboundCopy(t, env.uploads, audio)
|
||
md, ok := env.bridge.outboundVoiceMedia(directMessage(), voiceRefWithCopy(stored.Name, stored.Size))
|
||
if !ok {
|
||
t.Fatalf("副本在盘上却没挂上出站媒体(name=%s)", stored.Name)
|
||
}
|
||
if !bytes.Equal(md.Data, audio) {
|
||
t.Fatalf("读回的字节 = %q, want %q", md.Data, audio)
|
||
}
|
||
}
|