361 lines
12 KiB
Go
361 lines
12 KiB
Go
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)
|
||
}
|
||
}
|