Files
AskaEth 28afa11aeb feat(channelbridge): 出站语音结论落 voice 列并挂出站 Media(M58)
- voice.Ref 增 SourceTTS 与 FallbackReason,Validate/IsZero/validSource 同步
- 出站成功:INSERT -> 补写 voice 列 -> notify(首帧即带 voice)
- 出站降级:source=tts + status=failed + fallbackReason,正文原样
- 首条气泡挂出站 Media(新开条目,不复制 target.Media)
- gateway historyVoice 投影 fallbackReason
- 新增 outbound_voice.go 与整链 e2e 测试(含变异打红验证时序)
2026-10-27 22:08:38 +08:00

255 lines
9.4 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 voice
import (
"errors"
"strings"
"testing"
)
// TestEncodeDecodeRoundTrip 覆盖落库形状的核心往返:编码 → 解码逐字段还原。
// 三种真实形态各一条:有副本的 asr 真转写、无副本的 platform 转写(微信按既有裁决
// 不取字节)、无副本的失败(正文是那行中文诚实提示)。
func TestEncodeDecodeRoundTrip(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ref Ref
}{
{"asr 真转写 + 可播副本", Ref{
Source: SourceASR, Status: StatusTranscribed,
Name: "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg", MIME: "audio/ogg",
Size: 12345, DurationMs: 3200,
}},
{"platform 自带转写 + 无副本", Ref{
Source: SourcePlatform, Status: StatusTranscribed,
}},
{"转写失败 + 无副本(但原件仍可能落盘)", Ref{
Source: SourceASR, Status: StatusFailed, Name: "ffeeddccbbaa99887766554433221100.wav",
MIME: "audio/wav", Size: 160044,
}},
{"转写为空 + 有副本", Ref{
Source: SourceASR, Status: StatusEmpty, Name: "0123456789abcdef0123456789abcdef.mp3",
MIME: "audio/mpeg", Size: 4321,
}},
{"出站合成成功 + 可播副本", Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "fedcba9876543210fedcba9876543210.ogg", MIME: "audio/ogg",
Size: 8192, DurationMs: 1500,
}},
{"出站合成降级(正文照发文本)", Ref{
Source: SourceTTS, Status: StatusFailed, FallbackReason: "budget_exceeded",
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
raw, err := Encode(tc.ref)
if err != nil {
t.Fatalf("Encode: %v", err)
}
got, err := Decode(raw)
if err != nil {
t.Fatalf("Decode(%q): %v", raw, err)
}
if got != tc.ref {
t.Fatalf("往返 = %+v, want %+v", got, tc.ref)
}
})
}
}
// TestEncodeJSONShapeMatchesDesign 把**字面 JSON** 钉在设计上(design/24 §10.4 的示例):
// 字段顺序、键名、以及 durationMs 缺席(0 ⇒ omitempty)都必须逐字如此。
//
// 为什么需要字面量钉桩:这份 JSON 会落进 chat_messages.voice 列,是客户端与历史 DTO
// 共同的形状。字段顺序/键名漂移不会让任何单测变红(往返测试两侧同时漂移仍然相等),
// 只有把**值**钉死才能挡住(design/24 §9.7 的同一条测试质量教训)。
func TestEncodeJSONShapeMatchesDesign(t *testing.T) {
t.Parallel()
raw, err := Encode(Ref{
Source: SourceASR, Status: StatusTranscribed,
Name: "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg", MIME: "audio/ogg",
Size: 12345, DurationMs: 3200,
})
if err != nil {
t.Fatalf("Encode: %v", err)
}
want := `{"source":"asr","status":"transcribed","name":"a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg",` +
`"mime":"audio/ogg","size":12345,"durationMs":3200}`
if raw != want {
t.Fatalf("落库 JSON = %s\n want %s", raw, want)
}
// 无副本时 name/mime/size 三个键**一起缺席**(同生共死),durationMs 缺席(= 0)。
raw, err = Encode(Ref{Source: SourcePlatform, Status: StatusTranscribed})
if err != nil {
t.Fatalf("Encode(no copy): %v", err)
}
if raw != `{"source":"platform","status":"transcribed"}` {
t.Fatalf("无副本 JSON = %s", raw)
}
}
// TestEncodeZeroRefIsEmptyColumn 钉住「不是语音消息」的落库形状:零值编码成**空串**,
// 于是列里空串 ⇒ DTO 里 voice 键缺席(不是 {} 也不是 null)。
func TestEncodeZeroRefIsEmptyColumn(t *testing.T) {
t.Parallel()
raw, err := Encode(Ref{})
if err != nil {
t.Fatalf("Encode(零值): %v", err)
}
if raw != "" {
t.Fatalf("零值编码 = %q, want 空串", raw)
}
got, err := Decode("")
if err != nil {
t.Fatalf("Decode(\"\"): %v", err)
}
if !got.IsZero() {
t.Fatalf("Decode(\"\") = %+v, want 零值", got)
}
}
// TestEncodeRejectsBrokenRef 覆盖「同生共死」与闭集两条纪律的**写侧**执行:坏形状必须在
// 写库前报错,绝不悄悄补字段(那会让库里记着的东西与客户端读到的对不上)。
func TestEncodeRejectsBrokenRef(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ref Ref
}{
{"来源不在闭集", Ref{Source: "cloud", Status: StatusTranscribed}},
{"状态不在闭集", Ref{Source: SourceASR, Status: "ok"}},
{"来源为空", Ref{Status: StatusTranscribed}},
{"状态为空", Ref{Source: SourceASR}},
{"有 mime 无 name", Ref{Source: SourceASR, Status: StatusTranscribed, MIME: "audio/ogg"}},
{"有 size 无 name", Ref{Source: SourceASR, Status: StatusTranscribed, Size: 12}},
{"有 name 无 mime", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", Size: 12}},
{"有 name 无 size", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", MIME: "audio/ogg"}},
{"时长为负", Ref{Source: SourceASR, Status: StatusTranscribed, DurationMs: -1}},
{"降级原因挂在成功态", Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "x.ogg", MIME: "audio/ogg", Size: 12, FallbackReason: "budget_exceeded",
}},
{"只有降级原因", Ref{FallbackReason: "budget_exceeded"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
raw, err := Encode(tc.ref)
if err == nil {
t.Fatalf("Encode(%+v) = %q, want 错误(坏形状绝不入库)", tc.ref, raw)
}
if !errors.Is(err, ErrBadRef) {
t.Fatalf("错误 = %v, want ErrBadRef", err)
}
})
}
}
// TestDecodeRejectsGarbage 覆盖读侧:坏列内容**报错**而不是当成「不是语音」。
// 静默当成空 = 客户端永远看不到这条语音,而库里明明有引用(正是要消灭的静默)。
func TestDecodeRejectsGarbage(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
}{
{"截断的 JSON", `{"source":"asr"`},
{"数组形状(本列从诞生就是单对象)", `[{"source":"asr","status":"transcribed"}]`},
{"字面量 null", `null`},
{"空对象(两个闭集字段都缺)", `{}`},
{"闭集外的来源", `{"source":"cloud","status":"transcribed"}`},
{"闭集外的状态", `{"source":"asr","status":"done"}`},
{"副本字段不同生共死", `{"source":"asr","status":"transcribed","size":9}`},
{"降级原因挂在成功态", `{"source":"tts","status":"transcribed","fallbackReason":"x"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := Decode(tc.raw)
if err == nil {
t.Fatalf("Decode(%s) = %+v, want 错误", tc.raw, got)
}
if !errors.Is(err, ErrBadRef) {
t.Fatalf("错误 = %v, want ErrBadRef", err)
}
})
}
}
// TestOutboundTTSJSONShape 把**出站**两种落库形状钉死(design/25 §7.1):
// 成功 = source/status/name/mime/size(+ 可选 durationMs);降级 = source/status +
// fallbackReason,**没有 name/url**(正文照发文本,客户端据此只挂一个小标)。
func TestOutboundTTSJSONShape(t *testing.T) {
t.Parallel()
raw, err := Encode(Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "fedcba9876543210fedcba9876543210.ogg", MIME: "audio/ogg", Size: 8192,
})
if err != nil {
t.Fatalf("Encode(成功): %v", err)
}
want := `{"source":"tts","status":"transcribed","name":"fedcba9876543210fedcba9876543210.ogg",` +
`"mime":"audio/ogg","size":8192}`
if raw != want {
t.Fatalf("出站成功 JSON = %s\n want %s", raw, want)
}
raw, err = Encode(Ref{Source: SourceTTS, Status: StatusFailed, FallbackReason: "budget_exceeded"})
if err != nil {
t.Fatalf("Encode(降级): %v", err)
}
if want := `{"source":"tts","status":"failed","fallbackReason":"budget_exceeded"}`; raw != want {
t.Fatalf("出站降级 JSON = %s\n want %s", raw, want)
}
}
// TestRefIsVoiceAndHasCopy 钉住两个判据的边界:
// IsVoice = 来源与状态都在闭集内(= 这条消息来自一条语音);
// HasCopy = 有 name(name/mime/size 同生共死,因此 name 是代表)。
func TestRefIsVoiceAndHasCopy(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ref Ref
isVoice bool
hasCopy bool
}{
{"零值(不是语音消息)", Ref{}, false, false},
{"平台转写无副本", Ref{Source: SourcePlatform, Status: StatusTranscribed}, true, false},
{"有副本", Ref{Source: SourceASR, Status: StatusFailed, Name: "n.wav", MIME: "audio/wav", Size: 4}, true, true},
{"出站合成成功", Ref{Source: SourceTTS, Status: StatusTranscribed, Name: "n.ogg", MIME: "audio/ogg", Size: 4}, true, true},
{"出站降级(算语音消息,但无可播副本)", Ref{Source: SourceTTS, Status: StatusFailed, FallbackReason: "no_capability"}, true, false},
{"只有来源(半截,不算语音)", Ref{Source: SourceASR}, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := tc.ref.IsVoice(); got != tc.isVoice {
t.Fatalf("IsVoice = %v, want %v", got, tc.isVoice)
}
if got := tc.ref.HasCopy(); got != tc.hasCopy {
t.Fatalf("HasCopy = %v, want %v", got, tc.hasCopy)
}
})
}
}
// TestDecodeTrimsWhitespace 钉住宽容度只到「首尾空白」为止:列里带换行的内容(人手工
// 改过、或将来某处写入时多了换行)仍可读回,坏内容仍然报错。
func TestDecodeTrimsWhitespace(t *testing.T) {
t.Parallel()
raw := "\n {\"source\":\"asr\",\"status\":\"transcribed\"}\t \n"
got, err := Decode(raw)
if err != nil {
t.Fatalf("Decode(%q): %v", raw, err)
}
if got.Source != SourceASR || got.Status != StatusTranscribed {
t.Fatalf("Decode = %+v", got)
}
if strings.TrimSpace(raw) == "" {
t.Fatal("用例前提坏了:raw 不该是空串")
}
}