Files
Cyrene_v2/internal/runtime/loop/speak_tool_test.go
T
AskaEth 39ae883a4e test(loop): cover the speak tool and Result.Voice (M57)
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.
2026-10-27 21:22:20 +08:00

335 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 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))
}
}