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