speak_tool_test.go pins the leaf behaviour: budget formula, extension whitelist, argument validation, and the success/timeout/no-audio/store-failed paths, plus the model-surface advertisement and append-once rule. voice_result_test.go pins the outbound chain end to end: captureVoice's three states and its last-success-wins rule, a full turn where speak is advertised, executed through the injected synth/store and lands on Result.Voice, the degraded turn that still answers in text, the unwired turn that keeps the surface byte-identical, and the store adapter the wiring will use.
335 lines
11 KiB
Go
335 lines
11 KiB
Go
package loop
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||
)
|
||
|
||
// 本文件是 speak 工具的叶子测试(M57):预算公式、扩展名白名单、
|
||
// 参数校验、成功/超时/无音频/落盘失败四条执行路径,以及模型面的
|
||
// 广告与追加。依赖全部用桩,绝不真连 TTS、绝不真写盘。
|
||
|
||
// stubSynth 是可配置的合成桩:delay 制造超时,err 制造失败,
|
||
// audio 决定成功时的产物,provider 供日志断言。
|
||
type stubSynth struct {
|
||
audio mvoice.Audio
|
||
provider string
|
||
err error
|
||
delay time.Duration
|
||
gotText string
|
||
}
|
||
|
||
func (s *stubSynth) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
|
||
s.gotText = text
|
||
if s.delay > 0 {
|
||
select {
|
||
case <-time.After(s.delay):
|
||
case <-ctx.Done():
|
||
return mvoice.Audio{}, "", ctx.Err()
|
||
}
|
||
}
|
||
if s.err != nil {
|
||
return mvoice.Audio{}, "", s.err
|
||
}
|
||
return s.audio, s.provider, nil
|
||
}
|
||
|
||
// stubStore 是可配置的落盘桩,同时记录收到的原始名与字节。
|
||
type stubStore struct {
|
||
ref VoiceRef
|
||
err error
|
||
gotName string
|
||
gotData []byte
|
||
}
|
||
|
||
func (s *stubStore) SaveAudio(_ context.Context, origName string, data []byte) (VoiceRef, error) {
|
||
s.gotName = origName
|
||
s.gotData = data
|
||
if s.err != nil {
|
||
return VoiceRef{}, s.err
|
||
}
|
||
return s.ref, nil
|
||
}
|
||
|
||
// newSpeakTool 装配一个注入了桩的 speak 工具。
|
||
func newSpeakTool(synth VoiceSynth, store VoiceStore) speakTool {
|
||
return speakTool{synth: synth, store: store}
|
||
}
|
||
|
||
// speakCodeOf 取出 speak 失败的结构化原因码。
|
||
func speakCodeOf(t *testing.T, err error) *speakError {
|
||
t.Helper()
|
||
var se *speakError
|
||
if !errors.As(err, &se) {
|
||
t.Fatalf("error %v is not *speakError", err)
|
||
}
|
||
return se
|
||
}
|
||
|
||
// TestSpeakBudgetMs 锁住预算公式的边界:短句拿最低预算、线性增长、
|
||
// 长文封顶,负数按 0 处理(绝不返回负预算)。
|
||
func TestSpeakBudgetMs(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
runes int
|
||
want int
|
||
}{
|
||
{-5, speakBudgetBaseMs},
|
||
{0, speakBudgetBaseMs},
|
||
{1, speakBudgetBaseMs + speakBudgetPerRuneMs},
|
||
{100, speakBudgetBaseMs + 100*speakBudgetPerRuneMs},
|
||
{680, speakBudgetMaxMs},
|
||
{speakMaxRunes, speakBudgetMaxMs},
|
||
}
|
||
for _, c := range cases {
|
||
if got := speakBudgetMs(c.runes); got != c.want {
|
||
t.Errorf("speakBudgetMs(%d) = %d, want %d", c.runes, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestSpeakExt 锁住落盘扩展名:空格式按 wav(TTS 产物恒为 WAV)、
|
||
// 白名单内小写化、白名单外判不可用(绝不拿读不出 Content-Type 的
|
||
// 名字落盘)。
|
||
func TestSpeakExt(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
format string
|
||
want string
|
||
ok bool
|
||
}{
|
||
{"", "wav", true},
|
||
{" ", "wav", true},
|
||
{"WAV", "wav", true},
|
||
{"mp3", "mp3", true},
|
||
{" Opus ", "", false},
|
||
{"exe", "", false},
|
||
}
|
||
for _, c := range cases {
|
||
got, ok := speakExt(c.format)
|
||
if got != c.want || ok != c.ok {
|
||
t.Errorf("speakExt(%q) = (%q,%v), want (%q,%v)", c.format, got, ok, c.want, c.ok)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestParseSpeakArgs_Validation 锁住参数校验:缺 text、非对象、超长
|
||
// 都归 argument-validation(errors.Is(tool.ErrInvalidArgs)),并且
|
||
// 长度按 **rune** 算而不是字节(多字节文本不会因字节数被误判超长)。
|
||
func TestParseSpeakArgs_Validation(t *testing.T) {
|
||
t.Parallel()
|
||
if _, err := parseSpeakArgs(nil); !errors.Is(err, tool.ErrInvalidArgs) {
|
||
t.Fatalf("nil args err = %v, want ErrInvalidArgs", err)
|
||
}
|
||
if _, err := parseSpeakArgs(map[string]any{"text": " "}); !errors.Is(err, tool.ErrInvalidArgs) {
|
||
t.Fatalf("blank text err = %v, want ErrInvalidArgs", err)
|
||
}
|
||
if _, err := parseSpeakArgs([]string{"a"}); !errors.Is(err, tool.ErrInvalidArgs) {
|
||
t.Fatalf("non-object args err = %v, want ErrInvalidArgs", err)
|
||
}
|
||
if _, err := parseSpeakArgs(map[string]any{"text": strings.Repeat("あ", speakMaxRunes+1)}); !errors.Is(err, tool.ErrInvalidArgs) {
|
||
t.Fatalf("too-long text err = %v, want ErrInvalidArgs", err)
|
||
} else if code := speakCodeOf(t, err).Code; code != speakReasonTooLong {
|
||
t.Fatalf("too-long code = %q, want %q", code, speakReasonTooLong)
|
||
}
|
||
|
||
// 恰好上限、且是多字节:按 rune 计数,应当放行。
|
||
in, err := parseSpeakArgs(map[string]any{
|
||
"text": strings.Repeat("あ", speakMaxRunes),
|
||
"emotion": " 温柔 ",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("at-limit runes rejected: %v", err)
|
||
}
|
||
if in.runes != speakMaxRunes {
|
||
t.Fatalf("runes = %d, want %d(必须按 rune 而非字节计数)", in.runes, speakMaxRunes)
|
||
}
|
||
if in.emotion != "温柔" {
|
||
t.Fatalf("emotion = %q, want trimmed 温柔", in.emotion)
|
||
}
|
||
}
|
||
|
||
// TestSpeakTool_ExecuteSuccess 锁住成功路径的交付物:音频落盘用
|
||
// 上游实际格式的扩展名,返回值带齐 name/mime/size/budgetMs/costMs,
|
||
// 且 durationMs 恒为 0(未解析,不是零长度)。
|
||
func TestSpeakTool_ExecuteSuccess(t *testing.T) {
|
||
t.Parallel()
|
||
synth := &stubSynth{
|
||
audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}},
|
||
provider: "emotiontts",
|
||
}
|
||
store := &stubStore{ref: VoiceRef{Name: "abc123.wav", MIME: "audio/wav", Size: 3}}
|
||
tk := newSpeakTool(synth, store)
|
||
tk.budget = func(int) int { return 777 }
|
||
|
||
out, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if synth.gotText != "晚安" {
|
||
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
|
||
}
|
||
if store.gotName != "speak.wav" {
|
||
t.Fatalf("origName = %q, want speak.wav", store.gotName)
|
||
}
|
||
if len(store.gotData) != 3 {
|
||
t.Fatalf("stored bytes = %d, want 3", len(store.gotData))
|
||
}
|
||
m, ok := out.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("result type = %T, want map[string]any", out)
|
||
}
|
||
if m["name"] != "abc123.wav" || m["mime"] != "audio/wav" || m["size"] != int64(3) {
|
||
t.Fatalf("result = %v, want the stored ref's name/mime/size", m)
|
||
}
|
||
if m["durationMs"] != int64(0) {
|
||
t.Fatalf("durationMs = %v, want int64(0)(未解析)", m["durationMs"])
|
||
}
|
||
if m["budgetMs"] != 777 {
|
||
t.Fatalf("budgetMs = %v, want the injected 777", m["budgetMs"])
|
||
}
|
||
if _, ok := m["costMs"].(int); !ok {
|
||
t.Fatalf("costMs = %v, want int", m["costMs"])
|
||
}
|
||
}
|
||
|
||
// TestSpeakTool_ExecuteTimeout 锁住超时路径:预算到点即放弃合成,
|
||
// 原因码是 tts_timeout,且预算/实际耗时都回传给调用方(她下一轮
|
||
// 据此判断是「说短点」还是「上游坏了」)。这里用 1ms 预算 +
|
||
// 5ms 延迟制造真超时,不靠断言硬编码的时间差。
|
||
func TestSpeakTool_ExecuteTimeout(t *testing.T) {
|
||
t.Parallel()
|
||
tk := newSpeakTool(
|
||
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, delay: 5 * time.Millisecond},
|
||
&stubStore{},
|
||
)
|
||
tk.budget = func(int) int { return 1 }
|
||
|
||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||
se := speakCodeOf(t, err)
|
||
if se.Code != speakReasonTimeout {
|
||
t.Fatalf("code = %q, want %q", se.Code, speakReasonTimeout)
|
||
}
|
||
if se.BudgetMs != 1 {
|
||
t.Fatalf("BudgetMs = %d, want 1", se.BudgetMs)
|
||
}
|
||
if se.CostMs < 0 {
|
||
t.Fatalf("CostMs = %d, want >= 0", se.CostMs)
|
||
}
|
||
}
|
||
|
||
// TestSpeakTool_ExecuteNoAudio 锁住「上游说成功但没给可用音频」:
|
||
// 空字节与白名单外格式都归 tts_no_audio,绝不落一个空文件。
|
||
func TestSpeakTool_ExecuteNoAudio(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
audio mvoice.Audio
|
||
}{
|
||
{"empty bytes", mvoice.Audio{Format: "wav"}},
|
||
{"unknown format", mvoice.Audio{Format: "exe", Data: []byte{1}}},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
store := &stubStore{ref: VoiceRef{Name: "x.wav"}}
|
||
tk := newSpeakTool(&stubSynth{audio: c.audio, provider: "p"}, store)
|
||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||
if code := speakCodeOf(t, err).Code; code != speakReasonNoAudio {
|
||
t.Fatalf("code = %q, want %q", code, speakReasonNoAudio)
|
||
}
|
||
if store.gotName != "" {
|
||
t.Fatalf("store was called with %q, want no write at all", store.gotName)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestSpeakTool_ExecuteStoreFailed 锁住落盘失败:合成成功但副本存不下
|
||
// 时不谎报成功 —— 原因是 tts_store_failed,音频字节不会被当作交付物。
|
||
func TestSpeakTool_ExecuteStoreFailed(t *testing.T) {
|
||
t.Parallel()
|
||
tk := newSpeakTool(
|
||
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2}}, provider: "p"},
|
||
&stubStore{err: errors.New("disk full")},
|
||
)
|
||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||
se := speakCodeOf(t, err)
|
||
if se.Code != speakReasonStoreFailed {
|
||
t.Fatalf("code = %q, want %q", se.Code, speakReasonStoreFailed)
|
||
}
|
||
if !errors.Is(err, se.Err) {
|
||
t.Fatalf("error chain lost the store error: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestSpeakTool_NotConfigured 锁住「合成或落盘缺一个」:不 panic,
|
||
// 以结构化失败返回,使回合仍能继续。
|
||
func TestSpeakTool_NotConfigured(t *testing.T) {
|
||
t.Parallel()
|
||
for _, tk := range []speakTool{
|
||
newSpeakTool(nil, &stubStore{}),
|
||
newSpeakTool(&stubSynth{}, nil),
|
||
} {
|
||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||
if code := speakCodeOf(t, err).Code; code != speakReasonFailed {
|
||
t.Fatalf("code = %q, want %q", code, speakReasonFailed)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestSpeakSpec 锁住模型面的接线判定:两个依赖缺任一都不广告
|
||
// (模型面与接线前逐字节一致),都在时声明带参数 schema 的 speak。
|
||
func TestSpeakSpec(t *testing.T) {
|
||
t.Parallel()
|
||
if spec := New(Options{}).speakSpec(); spec != nil {
|
||
t.Fatalf("unwired speakSpec = %+v, want nil", spec)
|
||
}
|
||
if spec := New(Options{VoiceSynth: &stubSynth{}}).speakSpec(); spec != nil {
|
||
t.Fatalf("half-wired speakSpec = %+v, want nil", spec)
|
||
}
|
||
spec := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}}).speakSpec()
|
||
if spec == nil {
|
||
t.Fatal("wired speakSpec = nil, want a spec")
|
||
}
|
||
if spec.Name != speakToolName {
|
||
t.Fatalf("spec name = %q, want %q", spec.Name, speakToolName)
|
||
}
|
||
if spec.Parameters == nil {
|
||
t.Fatal("spec parameters = nil, want the JSON schema")
|
||
}
|
||
}
|
||
|
||
// TestWithSpeakSpec 锁住追加语义:未接线原样返回、接线后追加到末尾
|
||
// (因此不受前面任何收缩影响)、已有同名不重复追加。
|
||
func TestWithSpeakSpec(t *testing.T) {
|
||
t.Parallel()
|
||
base := []model.ToolSpec{{Name: "terminal"}, {Name: "web-search"}}
|
||
|
||
unwired := New(Options{})
|
||
if got := unwired.withSpeakSpec(base); len(got) != len(base) {
|
||
t.Fatalf("unwired appended: %d tools, want %d", len(got), len(base))
|
||
}
|
||
|
||
wired := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}})
|
||
got := wired.withSpeakSpec(base)
|
||
if len(got) != len(base)+1 {
|
||
t.Fatalf("wired tools = %d, want %d", len(got), len(base)+1)
|
||
}
|
||
if last := got[len(got)-1].Name; last != speakToolName {
|
||
t.Fatalf("last tool = %q, want %q(speak 必须在末尾)", last, speakToolName)
|
||
}
|
||
|
||
again := wired.withSpeakSpec(got)
|
||
if len(again) != len(got) {
|
||
t.Fatalf("duplicate speak appended: %d tools, want %d", len(again), len(got))
|
||
}
|
||
}
|