- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息
⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
297 lines
11 KiB
Go
297 lines
11 KiB
Go
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]
|
||
}
|