package tts // 本文件锁定 `Service`(emotionTTS + 既有链的门面)的三条口径: // // 1. **健康时用 emotionTTS**,并如实报告级别名 `emotiontts`; // 2. **失败时回落既有链**,且**不把失败上抛**(文本优先:合成挂掉不影响回复); // 3. **未启用时完全透传**(行为与引入本切片之前逐字相同),STT/克隆永远走既有链。 import ( "context" "errors" "io" "log/slog" "net/http" "testing" mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" ) // discardLogger 让测试不往 stderr 刷日志(WARN 的存在由行为断言,不靠肉眼)。 func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // fakeChain 是一个可编程的底链替身,记录自己被调了几次。 type fakeChain struct { ttsCalls int sttCalls int cloneCall int audio mvoice.Audio err error } func (f *fakeChain) STT(context.Context, mvoice.Audio) (string, error) { f.sttCalls++ return "转写文本", nil } func (f *fakeChain) STTWithProvider(context.Context, mvoice.Audio) (string, string, error) { f.sttCalls++ return "转写文本", "local", nil } func (f *fakeChain) TTS(context.Context, string) (mvoice.Audio, error) { f.ttsCalls++ return f.audio, f.err } func (f *fakeChain) TTSWithProvider(context.Context, string) (mvoice.Audio, string, error) { f.ttsCalls++ return f.audio, "silent", f.err } func (f *fakeChain) CloneVoice(context.Context, string, string) (string, error) { f.cloneCall++ return "voice-id", nil } // TestServiceUsesPrimaryWhenHealthy 钉住主路:健康时走 emotionTTS 且报告级别名。 func TestServiceUsesPrimaryWhenHealthy(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(wavBytes("her voice")) }) base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}} svc := NewService(client, base, WithServiceLogger(discardLogger())) audio, rung, err := svc.TTSWithProvider(context.Background(), chineseSpeech) if err != nil { t.Fatalf("TTSWithProvider: %v", err) } if rung != ProviderName { t.Errorf("rung = %q, want %q", rung, ProviderName) } if audio.Format != "wav" || string(audio.Data) == "fallback" { t.Errorf("audio = %+v, want the emotionTTS bytes", audio) } if base.ttsCalls != 0 { t.Errorf("主路健康却回落了底链 %d 次", base.ttsCalls) } } // TestServiceFallsBackWhenPrimaryFails 钉住降级:主路失败 ⇒ 底链出声音, // **错误不上抛**(文本优先)。 func TestServiceFallsBackWhenPrimaryFails(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"detail":"indexTTS2 down"}`)) }) base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}} svc := NewService(client, base, WithServiceLogger(discardLogger())) audio, rung, err := svc.TTSWithProvider(context.Background(), chineseSpeech) if err != nil { t.Fatalf("主路失败不该让合成整体失败(文本优先): %v", err) } if rung != "silent" { t.Errorf("rung = %q, want 底链报告的那一级", rung) } if string(audio.Data) != "fallback" { t.Errorf("audio = %+v, want 底链产物", audio) } if base.ttsCalls != 1 { t.Errorf("底链被调用 %d 次, want 1", base.ttsCalls) } if got := svc.Stats(); got.Failures != 1 { t.Errorf("降级没有计数: %+v", got) } } // TestServiceDisabledIsTransparent 钉住"未启用 = 行为逐字不变":底链原样服务, // 一次都不碰 emotionTTS 的 HTTP。 func TestServiceDisabledIsTransparent(t *testing.T) { client := New(Config{Enabled: false, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger())) if client.Enabled() { t.Fatal("未启用的客户端不该报告 enabled") } base := &fakeChain{audio: mvoice.Audio{Format: "ogg", Data: []byte("chain")}} svc := NewService(client, base, WithServiceLogger(discardLogger())) if svc.PrimaryActive() { t.Fatal("PrimaryActive = true, want false") } audio, _, err := svc.TTSWithProvider(context.Background(), chineseSpeech) if err != nil { t.Fatalf("TTSWithProvider: %v", err) } if audio.Format != "ogg" || base.ttsCalls != 1 { t.Errorf("未启用时没有原样透传: audio=%+v calls=%d", audio, base.ttsCalls) } } // TestServiceWithoutBase 钉住"没有底链"时的诚实失败(不 panic、可分类)。 func TestServiceWithoutBase(t *testing.T) { svc := NewService(nil, nil, WithServiceLogger(discardLogger())) if _, _, err := svc.TTSWithProvider(context.Background(), "hi"); !errors.Is(err, mvoice.ErrProviderFailed) { t.Fatalf("err = %v, want ErrProviderFailed", err) } if _, err := svc.STT(context.Background(), mvoice.Audio{}); !errors.Is(err, mvoice.ErrProviderFailed) { t.Fatalf("STT err = %v, want ErrProviderFailed", err) } if _, err := svc.CloneVoice(context.Background(), "http://x", "p"); !errors.Is(err, mvoice.ErrProviderFailed) { t.Fatalf("CloneVoice err = %v, want ErrProviderFailed", err) } } // TestServiceDelegatesSTTAndClone 钉住"只抢合成,不抢转写/复刻"。 func TestServiceDelegatesSTTAndClone(t *testing.T) { client := New(Config{Enabled: true, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger())) base := &fakeChain{} svc := NewService(client, base, WithServiceLogger(discardLogger())) if text, rung, err := svc.STTWithProvider(context.Background(), mvoice.Audio{}); err != nil || text != "转写文本" || rung != "local" { t.Fatalf("STT 没有原样委托: text=%q rung=%q err=%v", text, rung, err) } if id, err := svc.CloneVoice(context.Background(), "http://x", "p"); err != nil || id != "voice-id" { t.Fatalf("CloneVoice 没有原样委托: id=%q err=%v", id, err) } if base.sttCalls != 1 || base.cloneCall != 1 { t.Errorf("委托次数不对: stt=%d clone=%d", base.sttCalls, base.cloneCall) } } // TestProviderAdapter 钉住"作为链的一级"这条路现在就是通的(STT 方向为 nil)。 func TestProviderAdapter(t *testing.T) { client := New(Config{Enabled: true, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger())) p := NewProvider(client) if p.Name() != ProviderName { t.Errorf("Name = %q, want %q", p.Name(), ProviderName) } if p.STT() != nil { t.Error("emotionTTS 不提供转写,STT 应为 nil") } if _, err := p.TTS().TTS(context.Background(), " "); !errors.Is(err, ErrEmptyText) { t.Errorf("err = %v, want ErrEmptyText(穿透到 Client 的守卫)", err) } if _, err := NewProvider(nil).TTS().TTS(context.Background(), "hi"); !errors.Is(err, mvoice.ErrNotConfigured) { t.Errorf("未接线 err = %v, want ErrNotConfigured", err) } }