Files
Cyrene_v2/internal/runtime/voice/prepare_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

297 lines
11 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 (
"bytes"
"context"
"errors"
"os"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
)
// fakeTranscoder 是 Prepare 的能力门与转码调用的替身:它记录是否被调用、被问了哪条路,
// 让「可播格式**绝不**碰转码」这类纪律成为可断言的事实(而不是靠读代码相信)。
type fakeTranscoder struct {
// routes 是「源>目标」的可用集合;nil 表示按 DefaultCodecs 的口径只认 amr>wav。
routes map[string]bool
result transcode.Result
err error
calls int
lastReq transcode.Request
}
func routeKey(src, dst transcode.Format) string {
return transcode.FormatName(src) + ">" + transcode.FormatName(dst)
}
func newFakeTranscoder() *fakeTranscoder {
return &fakeTranscoder{routes: map[string]bool{routeKey(transcode.FormatAMR, transcode.FormatWAV): true}}
}
func (f *fakeTranscoder) Transcode(_ context.Context, req transcode.Request) (transcode.Result, error) {
f.calls++
f.lastReq = req
if f.err != nil {
return transcode.Result{}, f.err
}
res := f.result
res.Source = req.Source
if res.Format == "" {
res.Format = req.Target
}
return res, nil
}
func (f *fakeTranscoder) Supports(src, dst transcode.Format) bool {
return f.routes[routeKey(src, dst)]
}
func (f *fakeTranscoder) Formats() []transcode.Format {
return []transcode.Format{transcode.FormatAMR, transcode.FormatWAV}
}
// TestPrepareKeepsPlayableBytesAsIs 覆盖三态之①:源格式可播 ⇒ **原样落盘**,一个字节都
// 不改、也不碰转码(design/25 §7.1:不可播才转码;可播却转一圈等于白烧 CPU 还把音质
// 往下拽)。
func TestPrepareKeepsPlayableBytesAsIs(t *testing.T) {
t.Parallel()
tr := newFakeTranscoder()
in := []byte("RIFF....WAVEfmt ")
got, err := Prepare(context.Background(), tr, Prep{Data: in, Format: "WAV", DurationMs: 1500})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if !bytes.Equal(got.Data, in) {
t.Fatalf("字节被改动了:%q", got.Data)
}
if got.Format != "wav" || got.Transcoded {
t.Fatalf("产物 = %+v, want 原样 wav / Transcoded=false", got)
}
if got.DurationMs != 1500 {
t.Fatalf("时长 = %d, want 1500(渠道如实给出的值)", got.DurationMs)
}
if tr.calls != 0 {
t.Fatalf("转码被调用 %d 次, want 0(可播格式绝不转码)", tr.calls)
}
}
// TestPrepareTranscodesNonPlayableFormat 覆盖三态之②:不可播且转码可用 ⇒ 落**可播副本**,
// 并且时长优先取转码产物**可确证**的值(渠道常给不出;转码按帧数算得出来)。
func TestPrepareTranscodesNonPlayableFormat(t *testing.T) {
t.Parallel()
tr := newFakeTranscoder()
tr.result = transcode.Result{Data: []byte("RIFF-out"), Format: transcode.FormatWAV, DurationMs: 500}
got, err := Prepare(context.Background(), tr, Prep{Data: []byte("#!AMR\n..."), Format: "amr"})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if string(got.Data) != "RIFF-out" || got.Format != "wav" || !got.Transcoded {
t.Fatalf("产物 = %+v, want 转码产物 wav/Transcoded=true", got)
}
if got.DurationMs != 500 {
t.Fatalf("时长 = %d, want 500(转码产物可确证)", got.DurationMs)
}
if tr.calls != 1 {
t.Fatalf("转码调用次数 = %d, want 1", tr.calls)
}
if tr.lastReq.Source != transcode.FormatAMR || tr.lastReq.Target != transcode.FormatWAV {
t.Fatalf("请求 = %+v, want amr→wav", tr.lastReq)
}
}
// TestPreparePrefersTranscodedDuration 覆盖时长来源的优先级:转码产物给不出时长时,
// **沿用渠道如实给出的值**(绝不因为转码成功就把已知的时长抹成 0)。
func TestPreparePrefersTranscodedDuration(t *testing.T) {
t.Parallel()
tr := newFakeTranscoder()
tr.result = transcode.Result{Data: []byte("RIFF-out"), Format: transcode.FormatWAV}
got, err := Prepare(context.Background(), tr, Prep{Data: []byte("#!AMR\n"), Format: "amr", DurationMs: 3200})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if got.DurationMs != 3200 {
t.Fatalf("时长 = %d, want 3200(转码没给出 ⇒ 沿用渠道值)", got.DurationMs)
}
}
// TestPrepareNegativeDurationIsTreatedAsUnknown 钉住「渠道给了个无意义的值」的处理:
// 负数按**不知道**处理(0),既不采信也不编造 —— 落库前 Ref.Validate 会拒绝负数。
func TestPrepareNegativeDurationIsTreatedAsUnknown(t *testing.T) {
t.Parallel()
got, err := Prepare(context.Background(), nil, Prep{Data: []byte("RIFF"), Format: "wav", DurationMs: -5})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if got.DurationMs != 0 {
t.Fatalf("时长 = %d, want 0(负值 = 不知道)", got.DurationMs)
}
}
// prepareFailureCases 是「不可播且转码不可用」的全部失败面(design/25 §2.4 / §5.4):
// 每一条都要求「有错、可判类、无字节」。表放在用例之外,好让用例本体只表达纪律。
var prepareFailureCases = []struct {
name string
tr transcode.Transcoder
in Prep
wantErr error
category string
}{
{
name: "没有字节", tr: newFakeTranscoder(),
in: Prep{Data: nil, Format: "wav"},
wantErr: ErrNoBytes,
category: "no_bytes",
},
{
name: "不可播且没有登记目标(silk)", tr: newFakeTranscoder(),
in: Prep{Data: []byte("x"), Format: "silk"},
wantErr: ErrNotPlayable,
category: "not_playable",
},
{
name: "不可播且格式不认识", tr: newFakeTranscoder(),
in: Prep{Data: []byte("x"), Format: ""},
wantErr: ErrNotPlayable,
category: "not_playable",
},
{
name: "转码能力未接线(amr 但组合根没注入)", tr: nil,
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
wantErr: ErrNoTranscoder,
category: "transcode_not_wired",
},
{
name: "注册表里没有这条路(amr-wb 本期未实现)",
tr: newFakeTranscoder(),
in: Prep{Data: []byte("#!AMR-WB\n"), Format: "amr-wb"},
wantErr: transcode.ErrUnsupportedFormat,
category: "unsupported_format",
},
{
name: "输入越限", tr: failingTranscoder(transcode.ErrInputTooLarge),
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
wantErr: transcode.ErrInputTooLarge,
category: "input_too_large",
},
{
name: "产物越限", tr: failingTranscoder(transcode.ErrOutputTooLarge),
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
wantErr: transcode.ErrOutputTooLarge,
category: "output_too_large",
},
{
name: "解码失败(字节损坏)", tr: failingTranscoder(transcode.ErrDecodeFailed),
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
wantErr: transcode.ErrDecodeFailed,
category: "decode_failed",
},
{
name: "并发槽满(快速失败,不排队)", tr: failingTranscoder(transcode.ErrBusy),
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
wantErr: transcode.ErrBusy,
category: "busy",
},
{
name: "超时", tr: failingTranscoder(transcode.ErrTimeout),
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
wantErr: transcode.ErrTimeout,
category: "timeout",
},
}
// TestPrepareFailsHonestly 覆盖三态之③与全部失败分类(design/25 §2.4 / §5.4):
// **不可播且转码不可用**时绝不返回一份播不了却当作可播的结果;错误必须可 errors.Is
// 判定、可归类(Category 是「只进日志」的那份机器可读分类,design/24 §10.12-9)。
func TestPrepareFailsHonestly(t *testing.T) {
t.Parallel()
for _, tc := range prepareFailureCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := Prepare(context.Background(), tc.tr, tc.in)
if err == nil {
t.Fatalf("Prepare(%+v) = %+v, want 错误(绝不返回播不了却当作可播的结果)", tc.in, got)
}
if !errors.Is(err, tc.wantErr) {
t.Fatalf("错误 = %v, want %v", err, tc.wantErr)
}
if cat := Category(err); cat != tc.category {
t.Fatalf("Category(%v) = %q, want %q", err, cat, tc.category)
}
if len(got.Data) != 0 {
t.Fatalf("失败时仍返回了字节:%q", got.Data)
}
})
}
}
// failingTranscoder 返回一个自称有 amr→wav 这条路、但一转就报指定哨兵的替身。
func failingTranscoder(err error) *fakeTranscoder {
f := newFakeTranscoder()
f.err = err
return f
}
// TestPrepareRejectsEmptyTranscodeOutput 覆盖「转码器自称成功却没给字节」:那是目标侧
// 构造失败,绝不能当成一份空副本落盘(空文件播放不了,documents 侧也会按空文件拒掉)。
func TestPrepareRejectsEmptyTranscodeOutput(t *testing.T) {
t.Parallel()
tr := newFakeTranscoder()
tr.result = transcode.Result{Format: transcode.FormatWAV}
_, err := Prepare(context.Background(), tr, Prep{Data: []byte("#!AMR\n"), Format: "amr"})
if !errors.Is(err, transcode.ErrEncodeFailed) {
t.Fatalf("错误 = %v, want transcode.ErrEncodeFailed", err)
}
if cat := Category(err); cat != "encode_failed" {
t.Fatalf("Category = %q, want encode_failed", cat)
}
}
// TestCategoryOfNilIsEmpty 钉住 Category(nil) 的空串:调用方按它决定「要不要记一条
// 失败日志」,因此成功必须是空串(否则每次成功转码都会刷一条 WARN)。
func TestCategoryOfNilIsEmpty(t *testing.T) {
t.Parallel()
if cat := Category(nil); cat != "" {
t.Fatalf("Category(nil) = %q, want 空串", cat)
}
}
// TestPrepareWithRealTranscodeEngine 是**真走**用例(design/25 §8-T 的「复用既有能力」
// 验收):用真实 `transcode.Engine` + `DefaultCodecs()` 把一份真 AMR-NB 夹具走通
// `amr → wav`,断言产物是 RIFF/WAVE 且时长**可确证**。
//
// 夹具取自 transcode 包自己的 testdata(同一份,绝不再造一份):它是 opencore-amrnb
// 编出的 0.5 秒合成信号,因此本用例不需要装 ffmpeg。若该目录被搬走,本用例会**红**而不是
// 静默跳过(「跳过」会让这条唯一的真走证据消失而没人发现)。
func TestPrepareWithRealTranscodeEngine(t *testing.T) {
t.Parallel()
const fixture = "../../capability/transcode/testdata/mode12200.amr"
data, err := os.ReadFile(fixture)
if err != nil {
t.Fatalf("读夹具 %s: %v(真走用例依赖 transcode 包自己的 testdata)", fixture, err)
}
tr := transcode.NewEngine(transcode.DefaultLimits(),
transcode.WithCodecs(transcode.DefaultCodecs()...))
got, err := Prepare(context.Background(), tr, Prep{Data: data, Format: "amr"})
if err != nil {
t.Fatalf("Prepare(真 amr 夹具): %v", err)
}
if !got.Transcoded || got.Format != "wav" {
t.Fatalf("产物 = %+v, want wav 转码产物", got)
}
if len(got.Data) < 44 || string(got.Data[:4]) != "RIFF" || string(got.Data[8:12]) != "WAVE" {
t.Fatalf("产物不是 RIFF/WAVE:前 12 字节 = %q", head(got.Data, 12))
}
if got.DurationMs <= 0 {
t.Fatalf("时长 = %d, want >0(AMR 帧数可确证)", got.DurationMs)
}
}
// head 返回 data 的前 n 字节(越界时返回全部),只用于失败信息。
func head(data []byte, n int) []byte {
if len(data) < n {
return data
}
return data[:n]
}