Files
Cyrene_v2/internal/runtime/voice/voice_test.go
T
AskaEth ede00d9928 feat(voice): 语音消息落库 + 转码接入(切片 0020)
- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息

⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
2026-10-06 09:04:06 +08:00

212 lines
7.4 KiB
Go
Raw 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,
}},
}
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}},
}
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}`},
}
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)
}
})
}
}
// 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: 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 不该是空串")
}
}