Files
Cyrene_v2/internal/capability/transcode/engine_test.go
AskaEth 4a2d276881
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(capability): 转码能力包(vendor 纯 Go AMR-NB 解码器 + 黄金向量逐样本对照 + 8 哨兵 + 注册面)
2026-10-05 13:02:28 +08:00

361 lines
12 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 transcode
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync/atomic"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// stubCodec 是 engine 测试用的编解码器桩:它不碰真实音频,只记录调用次数并按
// 需要阻塞 / 报错,因此可以精确检验「闸在解码之前」这类顺序性断言。
type stubCodec struct {
from, to Format
calls atomic.Int32
// entered 在 Transcode 进入时被关闭(并发用例用来确认槽已被占)。
entered chan struct{}
// block 非 nil 时,Transcode 会等到它被关闭或 ctx 结束。
block chan struct{}
// waitCtx 为 true 时,Transcode 会一直等到 ctx 结束(超时用例)。
waitCtx bool
err error
result Result
}
func newStubCodec() *stubCodec { return &stubCodec{from: FormatAMR, to: FormatWAV} }
func (c *stubCodec) From() Format { return c.from }
func (c *stubCodec) To() Format { return c.to }
func (c *stubCodec) Transcode(ctx context.Context, in []byte, _ Limits) (Result, error) {
c.calls.Add(1)
if c.entered != nil {
select {
case c.entered <- struct{}{}:
default:
}
}
if c.block != nil {
select {
case <-c.block:
case <-ctx.Done():
return Result{}, ctx.Err()
}
}
if c.waitCtx {
<-ctx.Done()
return Result{}, ctx.Err()
}
if c.err != nil {
return Result{}, c.err
}
res := c.result
if res.Data == nil {
res.Data = in
}
if res.Source == "" {
res.Source = c.from
}
if res.Format == "" {
res.Format = c.to
}
return res, nil
}
// amrStubBytes 是一段「魔数与目标路都对得上」的最小输入。
func amrStubBytes(payload ...byte) []byte {
return append([]byte(amrNBMagic), payload...)
}
// TestEngineEmptyRegistry 是 design/25 §8-T1 的空注册表行为:Supports 恒 false、
// Transcode 恒 ErrUnsupportedFormat(且 Formats 为空)。
func TestEngineEmptyRegistry(t *testing.T) {
eng := NewEngine(DefaultLimits())
if eng.Supports(FormatAMR, FormatWAV) {
t.Fatalf("empty registry reports support for amr -> wav")
}
if got := eng.Formats(); len(got) != 0 {
t.Fatalf("empty registry formats = %v, want none", got)
}
_, err := eng.Transcode(context.Background(), Request{
Data: amrStubBytes(0x3c), Source: FormatAMR, Target: FormatWAV,
})
if !errors.Is(err, ErrUnsupportedFormat) {
t.Fatalf("err = %v, want ErrUnsupportedFormat", err)
}
}
// TestEngineInputTooLargeDecodesNothing 断言输入字节闸在**编解码器之前**:桩一次都
// 没被调用(design/25 §8-T1「越限 ⇒ ErrInputTooLarge 且一行都没解码」)。
func TestEngineInputTooLargeDecodesNothing(t *testing.T) {
stub := newStubCodec()
limits := DefaultLimits()
limits.MaxInputBytes = len(amrNBMagic)
eng := NewEngine(limits, WithCodecs(stub))
data := amrStubBytes(0x3c, 0x00)
_, err := eng.Transcode(context.Background(), Request{Data: data, Source: FormatAMR, Target: FormatWAV})
if !errors.Is(err, ErrInputTooLarge) {
t.Fatalf("err = %v, want ErrInputTooLarge", err)
}
if n := stub.calls.Load(); n != 0 {
t.Fatalf("codec was invoked %d times before the input-size gate", n)
}
}
// TestEngineBusyFailsFast 断言并发槽满时**快速失败、不入队**(design/25 §5.2),
// 且槽随后会被归还。
func TestEngineBusyFailsFast(t *testing.T) {
stub := newStubCodec()
stub.block = make(chan struct{})
stub.entered = make(chan struct{}, 1)
limits := DefaultLimits()
limits.Concurrency = 1
eng := NewEngine(limits, WithCodecs(stub))
done := make(chan error, 1)
go func() {
_, err := eng.Transcode(context.Background(), Request{
Data: amrStubBytes(0x3c), Source: FormatAMR, Target: FormatWAV,
})
done <- err
}()
<-stub.entered
start := time.Now()
_, err := eng.Transcode(context.Background(), Request{
Data: amrStubBytes(0x3c), Source: FormatAMR, Target: FormatWAV,
})
if !errors.Is(err, ErrBusy) {
t.Fatalf("err = %v, want ErrBusy", err)
}
if elapsed := time.Since(start); elapsed > busyFailBudget {
t.Fatalf("busy rejection took %s (should fail fast, not queue)", elapsed)
}
close(stub.block)
if err := <-done; err != nil {
t.Fatalf("first call: %v", err)
}
if _, err := eng.Transcode(context.Background(), Request{
Data: amrStubBytes(0x3c), Source: FormatAMR, Target: FormatWAV,
}); err != nil {
t.Fatalf("slot not released after the first call: %v", err)
}
}
// busyFailBudget 是「快速失败」的时间预算:ErrBusy 必须来自非阻塞的 select,
// 而不是等出来的。取值远大于调度抖动(同一台机器上实测 <1ms)。
const busyFailBudget = 500 * time.Millisecond
// TestEngineTimeout 断言单次 ctx 超时是硬闸:超时即 ErrTimeout,且调用方拿得到
// (不无界等待、不泄漏 goroutine)。
func TestEngineTimeout(t *testing.T) {
stub := newStubCodec()
stub.waitCtx = true
limits := DefaultLimits()
limits.Timeout = 20 * time.Millisecond
eng := NewEngine(limits, WithCodecs(stub))
start := time.Now()
_, err := eng.Transcode(context.Background(), Request{
Data: amrStubBytes(0x3c), Source: FormatAMR, Target: FormatWAV,
})
if !errors.Is(err, ErrTimeout) {
t.Fatalf("err = %v, want ErrTimeout", err)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("timeout took %s, want ~%s", elapsed, limits.Timeout)
}
}
// TestEngineRejectsBadRequests 覆盖「进入编解码器之前」的几种诚实拒绝。
func TestEngineRejectsBadRequests(t *testing.T) {
eng := NewEngine(DefaultLimits(), WithCodecs(newStubCodec()))
cases := []struct {
name string
req Request
}{
{"empty target", Request{Data: amrStubBytes(0x3c), Source: FormatAMR}},
{"source not sniffable", Request{Data: []byte("not audio at all"), Target: FormatWAV}},
{"no route for sniffed source", Request{Data: []byte("RIFF\x00\x00\x00\x00WAVE"), Target: FormatWAV}},
{"unknown source name", Request{Data: amrStubBytes(0x3c), Source: Format("mp3"), Target: FormatWAV}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := eng.Transcode(context.Background(), tc.req)
if !errors.Is(err, ErrUnsupportedFormat) {
t.Fatalf("err = %v, want ErrUnsupportedFormat", err)
}
})
}
}
// TestEngineSniffsSourceWhenOmitted 断言 Source 留空时按魔数嗅探并回填到 Result。
func TestEngineSniffsSourceWhenOmitted(t *testing.T) {
eng := NewEngine(DefaultLimits(), WithCodecs(newStubCodec()))
res, err := eng.Transcode(context.Background(), Request{Data: amrStubBytes(0x3c), Target: FormatWAV})
if err != nil {
t.Fatalf("transcode: %v", err)
}
if res.Source != FormatAMR {
t.Fatalf("sniffed source = %q, want amr", res.Source)
}
}
// TestEngineCodecContractViolations 断言「不该发生的」被当成失败:空产物 ⇒
// ErrEncodeFailed;非哨兵错误 ⇒ 原样上抛(但记 ERROR 级日志,绝不静默)。
func TestEngineCodecContractViolations(t *testing.T) {
t.Run("empty output", func(t *testing.T) {
stub := newStubCodec()
stub.result = Result{Data: []byte{}}
stub.err = nil
eng := NewEngine(DefaultLimits(), WithCodecs(stub))
_, err := eng.Transcode(context.Background(), Request{
Data: amrStubBytes(0x3c), Source: FormatAMR, Target: FormatWAV,
})
if !errors.Is(err, ErrEncodeFailed) {
t.Fatalf("err = %v, want ErrEncodeFailed", err)
}
})
t.Run("non-sentinel error is not swallowed", func(t *testing.T) {
var buf bytes.Buffer
stub := newStubCodec()
stub.err = errBoom
eng := NewEngine(DefaultLimits(), WithCodecs(stub), WithLogger(testLogger(&buf)))
_, err := eng.Transcode(context.Background(), Request{
Data: amrStubBytes(0x3c), Source: FormatAMR, Target: FormatWAV,
})
if !errors.Is(err, errBoom) {
t.Fatalf("err = %v, want the codec error to pass through", err)
}
assertLogContains(t, &buf, "level=ERROR", "transcode failed", "source=capability/transcode")
})
}
// errBoom 是「编解码器违约」用的非哨兵错误。
var errBoom = errors.New("boom")
// testLogger 造一个按组合根口径接线的日志器(source=capability/transcode)。
func testLogger(buf *bytes.Buffer) *slog.Logger {
return logger.WithSource(slog.New(slog.NewTextHandler(buf, nil)), logSource)
}
// logSource 是本能力的日志来源(design/25 §5.3;组合根必须照此接线)。
const logSource = "capability/transcode"
// assertLogContains 断言日志里出现了给定的每一段子串。
func assertLogContains(t *testing.T, buf *bytes.Buffer, wants ...string) {
t.Helper()
got := buf.String()
for _, want := range wants {
if !strings.Contains(got, want) {
t.Fatalf("log missing %q; got:\n%s", want, got)
}
}
}
// TestEngineLogsEveryOutcome 断言 design/25 §5.3 要求的字段与来源都在:
// 成功记 Info(含 in/out 字节数、帧数、时长、耗时),失败记 Warn。
// 同时断言「只记长度、不记字节内容」。
func TestEngineLogsEveryOutcome(t *testing.T) {
t.Run("success", func(t *testing.T) {
var buf bytes.Buffer
stub := newStubCodec()
eng := NewEngine(DefaultLimits(), WithCodecs(stub), WithLogger(testLogger(&buf)))
if _, err := eng.Transcode(context.Background(), Request{
Data: amrStubBytes(0x3c), Source: FormatAMR, Target: FormatWAV,
}); err != nil {
t.Fatalf("transcode: %v", err)
}
assertLogContains(t, &buf, "level=INFO", "transcode ok", "source=capability/transcode",
"source_format=amr", "target_format=wav", "in_bytes=7", "out_bytes=7", "frames=0")
})
t.Run("unsupported is a warning", func(t *testing.T) {
var buf bytes.Buffer
eng := NewEngine(DefaultLimits(), WithLogger(testLogger(&buf)))
if _, err := eng.Transcode(context.Background(), Request{
Data: amrStubBytes(0x3c), Source: FormatAMR, Target: FormatWAV,
}); !errors.Is(err, ErrUnsupportedFormat) {
t.Fatalf("err = %v", err)
}
assertLogContains(t, &buf, "level=WARN", "transcode failed", "source=capability/transcode")
})
}
// TestEngineRegistrationErrorsAreVisible 断言注册失败不阻塞构造、但**可见**
// (绝不静默):nil 注册项、重复注册都会进 RegistrationError,且好的一路仍可用。
func TestEngineRegistrationErrorsAreVisible(t *testing.T) {
cases := []struct {
name string
codecs []Codec
wantErr error
}{
{"nil codec", []Codec{nil, newStubCodec()}, ErrInvalidCodec},
{"duplicate codec", []Codec{newStubCodec(), newStubCodec()}, ErrDuplicate},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
eng := NewEngine(DefaultLimits(), WithCodecs(tc.codecs...), WithLogger(testLogger(&buf)))
err := eng.RegistrationError()
if !errors.Is(err, tc.wantErr) {
t.Fatalf("RegistrationError = %v, want %v", err, tc.wantErr)
}
if !eng.Supports(FormatAMR, FormatWAV) {
t.Fatalf("good codec was not registered")
}
assertLogContains(t, &buf, "level=ERROR", "transcode codec registration skipped")
})
}
}
// TestEngineDefaultLimits 断言零值 Limits 被补成 design/25 §2.5 的具名默认,
// 且显式给出的值不被覆盖。
func TestEngineDefaultLimits(t *testing.T) {
got := NewEngine(Limits{}).Limits()
want := DefaultLimits()
if got != want {
t.Fatalf("zero limits = %+v, want defaults %+v", got, want)
}
custom := Limits{MaxInputBytes: 1, MaxOutputBytes: 2, MaxDuration: 3, Concurrency: 4, Timeout: 5}
if got := NewEngine(custom).Limits(); got != custom {
t.Fatalf("explicit limits = %+v, want untouched %+v", got, custom)
}
}
// TestEngineNilContextIsSafe 断言 ctx 为 nil 不会 panic(按 Background 处理)。
func TestEngineNilContextIsSafe(t *testing.T) {
eng := NewEngine(DefaultLimits(), WithCodecs(newStubCodec()))
if _, err := eng.Transcode(nil, Request{ //nolint:staticcheck // 故意传 nil ctx
Data: amrStubBytes(0x3c), Source: FormatAMR, Target: FormatWAV,
}); err != nil {
t.Fatalf("nil ctx: %v", err)
}
}
// TestEngineErrorsAreWrapped 断言对外错误都能被 errors.Is 命中哨兵,且带上下文
// (不是裸 err):文案形状是 `transcode: <源> -> <目标>: <原因>`。
func TestEngineErrorsAreWrapped(t *testing.T) {
stub := newStubCodec()
stub.err = fmt.Errorf("stub: %w", ErrDecodeFailed)
eng := NewEngine(DefaultLimits(), WithCodecs(stub))
_, err := eng.Transcode(context.Background(), Request{
Data: amrStubBytes(0x3c), Source: FormatAMR, Target: FormatWAV,
})
if !errors.Is(err, ErrDecodeFailed) {
t.Fatalf("err = %v, want ErrDecodeFailed", err)
}
if !strings.Contains(err.Error(), "stub") {
t.Fatalf("wrapped error lost its cause: %v", err)
}
const wantPrefix = "transcode: amr -> wav: "
if !strings.HasPrefix(err.Error(), wantPrefix) {
t.Fatalf("err = %q, want the %q prefix(带路由上下文)", err.Error(), wantPrefix)
}
}