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 不该是空串") } }