diff --git a/internal/runtime/loop/speak_tool_test.go b/internal/runtime/loop/speak_tool_test.go new file mode 100644 index 0000000..0625d10 --- /dev/null +++ b/internal/runtime/loop/speak_tool_test.go @@ -0,0 +1,334 @@ +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)) + } +} diff --git a/internal/runtime/loop/voice_result_test.go b/internal/runtime/loop/voice_result_test.go new file mode 100644 index 0000000..54ae839 --- /dev/null +++ b/internal/runtime/loop/voice_result_test.go @@ -0,0 +1,263 @@ +package loop + +import ( + "context" + "errors" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" + "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 调用 → Result.Voice」这条出站链(M57): +// 成功、降级、没调三态在叶子层,加上一次端到端回合,证明模型面真的 +// 被广告了 speak、调用真的被执行、结论真的落在 Result 上。 + +// TestCaptureVoice_States 锁住三态:成功带交付物、失败带原因码与 +// 预算/耗时、没调(或只调了别的工具)返回 nil。 +func TestCaptureVoice_States(t *testing.T) { + t.Parallel() + l := New(Options{}) + + if v := l.captureVoice( + []agent.ToolCall{{Name: "terminal"}}, + []ToolOutcome{{Result: "ok"}}, + ); v != nil { + t.Fatalf("non-speak batch voice = %+v, want nil", v) + } + + ok := l.captureVoice( + []agent.ToolCall{{Name: speakToolName}}, + []ToolOutcome{{Result: map[string]any{ + "name": "abc.wav", "mime": "audio/wav", "size": int64(9), + "durationMs": int64(0), "budgetMs": 3025, "costMs": 120, + }}}, + ) + if ok == nil || !ok.Requested { + t.Fatalf("success voice = %+v, want Requested", ok) + } + if ok.Name != "abc.wav" || ok.Mime != "audio/wav" || ok.Size != 9 { + t.Fatalf("success voice = %+v, want the stored ref", ok) + } + if ok.BudgetMs != 3025 || ok.CostMs != 120 || ok.DurationMs != 0 { + t.Fatalf("success voice = %+v, want budget/cost and unparsed duration", ok) + } + if ok.FallbackReason != "" { + t.Fatalf("success FallbackReason = %q, want empty", ok.FallbackReason) + } + + bad := l.captureVoice( + []agent.ToolCall{{Name: speakToolName}}, + []ToolOutcome{{Err: &speakError{Code: speakReasonTimeout, BudgetMs: 3000, CostMs: 3100}}}, + ) + if bad == nil || !bad.Requested { + t.Fatalf("failure voice = %+v, want Requested", bad) + } + if bad.FallbackReason != speakReasonTimeout || bad.BudgetMs != 3000 || bad.CostMs != 3100 { + t.Fatalf("failure voice = %+v, want the timeout code and budget/cost", bad) + } + if bad.Name != "" { + t.Fatalf("failure name = %q, want empty(没有交付物)", bad.Name) + } + + // 非结构化失败也要有原因码,绝不把空串交给客户端。 + raw := l.captureVoice( + []agent.ToolCall{{Name: speakToolName}}, + []ToolOutcome{{Err: errors.New("boom")}}, + ) + if raw == nil || raw.FallbackReason != speakReasonFailed { + t.Fatalf("raw failure voice = %+v, want %q", raw, speakReasonFailed) + } +} + +// TestCaptureVoice_LastSuccessWins 锁住多次 speak 的取用规则:成功优先 +// 于失败(音频已经落盘就交付),同类取最后一个(这条消息最后的意愿)。 +func TestCaptureVoice_LastSuccessWins(t *testing.T) { + t.Parallel() + l := New(Options{}) + calls := []agent.ToolCall{{Name: speakToolName}, {Name: speakToolName}, {Name: speakToolName}} + + // 先成功、后失败:交付已落盘的那份。 + got := l.captureVoice(calls[:2], []ToolOutcome{ + {Result: map[string]any{"name": "first.wav", "mime": "audio/wav", "size": int64(1)}}, + {Err: &speakError{Code: speakReasonFailed}}, + }) + if got == nil || got.Name != "first.wav" { + t.Fatalf("voice = %+v, want first.wav(成功优先于后续失败)", got) + } + + // 两次都成功:取后者。 + got = l.captureVoice(calls[:2], []ToolOutcome{ + {Result: map[string]any{"name": "first.wav"}}, + {Result: map[string]any{"name": "second.wav"}}, + }) + if got == nil || got.Name != "second.wav" { + t.Fatalf("voice = %+v, want second.wav(后者覆盖前者)", got) + } + + // 被跳过的调用不算数。 + if v := l.captureVoice(calls[:1], []ToolOutcome{{Skipped: true, Reason: "duplicate"}}); v != nil { + t.Fatalf("skipped voice = %+v, want nil", v) + } +} + +// TestRun_VoiceResult 是一次端到端回合:speak 出现在模型面里、被调用 +// 后真的走了注入的合成与落盘,结论落在 Result.Voice 上。 +func TestRun_VoiceResult(t *testing.T) { + t.Parallel() + call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{ + ID: "call_speak_1", + Name: speakToolName, + Args: map[string]any{"text": "晚安", "emotion": "温柔"}, + }}} + ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}} + td := dispatcherWith(t, &mockTool{name: "terminal", result: "ok"}) + synth := &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}}, provider: "emotiontts"} + store := &stubStore{ref: VoiceRef{Name: "abc.wav", MIME: "audio/wav", Size: 3}} + l := New(Options{ + Agent: newTestAgent(t, ad, td), + MaxRounds: 8, + OnStream: func(model.StreamEvent) {}, + VoiceSynth: synth, + VoiceStore: store, + }) + + res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ToolCallsExecuted != 1 { + t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted) + } + // ① 模型面确实被广告了 speak(否则模型不可能调它)。 + if len(ad.streamReqs) == 0 { + t.Fatal("no streaming request captured") + } + if set := toolSet(ad.streamReqs[0].Tools); !set[speakToolName] { + t.Fatalf("advertised tools = %v, want %q", toolNames(ad.streamReqs[0].Tools), speakToolName) + } + // ② 调用真的走了注入的合成与落盘。 + 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) + } + // ③ 结论落在 Result.Voice 上。 + if res.Voice == nil { + t.Fatal("Result.Voice = nil, want the synthesized audio") + } + if !res.Voice.Requested || res.Voice.Name != "abc.wav" || res.Voice.Mime != "audio/wav" || res.Voice.Size != 3 { + t.Fatalf("Result.Voice = %+v, want the stored ref", res.Voice) + } + if res.Voice.FallbackReason != "" { + t.Fatalf("FallbackReason = %q, want empty", res.Voice.FallbackReason) + } +} + +// TestRun_VoiceDegraded 锁住端到端的降级:合成失败时回合照常结束 +// (绝不因语音失败而失败),Result.Voice 带上原因码让客户端特殊显示。 +func TestRun_VoiceDegraded(t *testing.T) { + t.Parallel() + call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{ + ID: "call_speak_2", + Name: speakToolName, + Args: map[string]any{"text": "晚安"}, + }}} + ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}} + l := New(Options{ + Agent: newTestAgent(t, ad, dispatcherWith(t)), + MaxRounds: 8, + OnStream: func(model.StreamEvent) {}, + VoiceSynth: &stubSynth{err: errors.New("upstream down")}, + VoiceStore: &stubStore{}, + }) + + res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Text != "晚安" { + t.Fatalf("Text = %q, want the text answer(语音失败不得拖垮回合)", res.Text) + } + if res.Voice == nil || res.Voice.FallbackReason != speakReasonFailed { + t.Fatalf("Result.Voice = %+v, want %q", res.Voice, speakReasonFailed) + } + if res.Voice.Requested != true { + t.Fatalf("Result.Voice.Requested = %v, want true(确实想用语音)", res.Voice.Requested) + } +} + +// TestRun_NoSpeakNoVoice 锁住「不接线就没有语音面」:未注入依赖时 +// 模型面里没有 speak,Result.Voice 保持 nil —— 与接线前逐字节一致。 +func TestRun_NoSpeakNoVoice(t *testing.T) { + t.Parallel() + ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Model: "m", Content: "晚安"}}}} + l := New(Options{ + Agent: newTestAgent(t, ad, dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})), + MaxRounds: 8, + OnStream: func(model.StreamEvent) {}, + }) + + res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Voice != nil { + t.Fatalf("Result.Voice = %+v, want nil", res.Voice) + } + if len(ad.streamReqs) == 0 { + t.Fatal("no streaming request captured") + } + if set := toolSet(ad.streamReqs[0].Tools); set[speakToolName] { + t.Fatalf("advertised tools = %v, must not contain %q", toolNames(ad.streamReqs[0].Tools), speakToolName) + } +} + +// TestInvokeTool_SpeakWithoutDispatcher 锁住 speak 不依赖 dispatcher: +// 未接线时普通工具仍按原样报「没有 dispatcher」,接线后 speak 照常执行。 +func TestInvokeTool_SpeakWithoutDispatcher(t *testing.T) { + t.Parallel() + wired := New(Options{ + VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"}, + VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}}, + }) + out := wired.invokeOne(context.Background(), nil, agent.ToolCall{ + Name: speakToolName, Args: map[string]any{"text": "晚安"}, + }) + if out.Err != nil { + t.Fatalf("speak without dispatcher err = %v, want nil", out.Err) + } + + plain := New(Options{}) + out = plain.invokeOne(context.Background(), nil, agent.ToolCall{Name: "terminal"}) + if out.Err == nil || out.ErrKind != tool.KindExecution { + t.Fatalf("nil dispatcher outcome = %+v, want an execution error", out) + } +} + +// TestVoiceStoreFunc 锁住接线方用的函数适配器:它满足 VoiceStore,且 +// 原样转发原始名与字节(接线方正是靠它把 documents.Store 接到接缝上, +// 而不让循环反向 import documents)。 +func TestVoiceStoreFunc(t *testing.T) { + t.Parallel() + var gotName string + var gotData []byte + var store VoiceStore = VoiceStoreFunc(func(_ context.Context, origName string, data []byte) (VoiceRef, error) { + gotName, gotData = origName, data + return VoiceRef{Name: "saved.wav", MIME: "audio/wav", Size: int64(len(data))}, nil + }) + + ref, err := store.SaveAudio(context.Background(), "speak.wav", []byte{7, 8}) + if err != nil { + t.Fatalf("SaveAudio: %v", err) + } + if gotName != "speak.wav" || len(gotData) != 2 { + t.Fatalf("forwarded (%q, %d bytes), want (speak.wav, 2 bytes)", gotName, len(gotData)) + } + if ref.Name != "saved.wav" || ref.Size != 2 { + t.Fatalf("ref = %+v, want the adapter's own result", ref) + } +}