package tts import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "go.uber.org/zap" ) // mockMiMoTTSServer 创建模拟 MiMo TTS API 的 HTTP 服务器。 func mockMiMoTTSServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { t.Helper() return httptest.NewServer(handler) } // buildMiMoTTSResponse 构造 MiMo TTS 非流式响应 JSON。 func buildMiMoTTSResponse(audioData string) []byte { resp := mimoTTSResponse{ Choices: []struct { Message struct { Audio struct { Data string `json:"data"` } `json:"audio"` } `json:"message"` }{ { Message: struct { Audio struct { Data string `json:"data"` } `json:"audio"` }{ Audio: struct { Data string `json:"data"` }{Data: audioData}, }, }, }, } data, _ := json.Marshal(resp) return data } func TestMiMoService_SynthesizeStream_Success(t *testing.T) { var callCount int32 srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&callCount, 1) if r.Method != http.MethodPost { t.Errorf("method = %s, want POST", r.Method) } if !strings.Contains(r.URL.Path, "/chat/completions") { t.Errorf("path = %s, should contain /chat/completions", r.URL.Path) } // 验证 api-key 认证头 apiKey := r.Header.Get("api-key") if apiKey != "test-key" { t.Errorf("api-key = %q, want %q", apiKey, "test-key") } // 验证请求体 body, _ := io.ReadAll(r.Body) var req mimoTTSRequest if err := json.Unmarshal(body, &req); err != nil { t.Errorf("unmarshal request: %v", err) } if req.Model != "mimo-v2.5-tts" { t.Errorf("model = %q, want %q", req.Model, "mimo-v2.5-tts") } if len(req.Messages) != 1 || req.Messages[0].Role != "assistant" { t.Errorf("expected 1 assistant message, got %d messages", len(req.Messages)) } if req.Audio.Voice != "冰糖" { t.Errorf("voice = %q, want %q", req.Audio.Voice, "冰糖") } if req.Audio.Format != "mp3" { t.Errorf("format = %q, want %q", req.Audio.Format, "mp3") } // 返回假音频数据(base64 编码) audioB64 := base64.StdEncoding.EncodeToString([]byte("fake-mp3-data")) w.Header().Set("Content-Type", "application/json") w.Write(buildMiMoTTSResponse(audioB64)) }) defer srv.Close() svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 5, zap.NewNop().Sugar()) textStream := sendSentences("你好", "世界", "!") ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{ Voice: "冰糖", OutputFmt: "mp3", SampleRate: 24000, }) if err != nil { t.Fatalf("SynthesizeStream() error: %v", err) } var chunks []Chunk for c := range ch { chunks = append(chunks, c) } // 应该有 3 个音频 chunk + 1 个 IsLast 标记 if len(chunks) != 4 { t.Fatalf("got %d chunks, want 4", len(chunks)) } // 验证前 3 个有音频数据 for i := 0; i < 3; i++ { if string(chunks[i].Audio) != "fake-mp3-data" { t.Errorf("chunk[%d].Audio = %q, want %q", i, string(chunks[i].Audio), "fake-mp3-data") } if chunks[i].IsLast { t.Errorf("chunk[%d].IsLast should be false", i) } } // 验证最后一个是 IsLast if !chunks[3].IsLast { t.Error("last chunk should be IsLast") } if chunks[3].Audio != nil { t.Error("last chunk Audio should be nil") } // 验证调用了 3 次 API(3 个句子) if atomic.LoadInt32(&callCount) != 3 { t.Errorf("API called %d times, want 3", callCount) } } func TestMiMoService_SynthesizeStream_APIError(t *testing.T) { srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) fmt.Fprintf(w, "internal error") }) defer srv.Close() svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 5, zap.NewNop().Sugar()) textStream := sendSentences("你好") ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{}) if err != nil { t.Fatalf("SynthesizeStream() error: %v", err) } // 应该只有一个 IsLast chunk(音频被跳过) var chunks []Chunk for c := range ch { chunks = append(chunks, c) } if len(chunks) != 1 { t.Fatalf("got %d chunks, want 1 (IsLast only)", len(chunks)) } if !chunks[0].IsLast { t.Error("chunk should be IsLast") } } func TestMiMoService_SynthesizeStream_Timeout(t *testing.T) { srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) { time.Sleep(3 * time.Second) audioB64 := base64.StdEncoding.EncodeToString([]byte("late-mp3")) w.Header().Set("Content-Type", "application/json") w.Write(buildMiMoTTSResponse(audioB64)) }) defer srv.Close() // 1 秒超时 svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 1, zap.NewNop().Sugar()) textStream := sendSentences("很长的句子") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() ch, err := svc.SynthesizeStream(ctx, textStream, Options{}) if err != nil { t.Fatalf("SynthesizeStream() error: %v", err) } var chunks []Chunk for c := range ch { chunks = append(chunks, c) } // 超时后音频被跳过,只有 IsLast if len(chunks) != 1 { t.Fatalf("got %d chunks, want 1", len(chunks)) } if !chunks[0].IsLast { t.Error("chunk should be IsLast") } } func TestMiMoService_SynthesizeStream_EmptyText(t *testing.T) { var callCount int32 srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&callCount, 1) audioB64 := base64.StdEncoding.EncodeToString([]byte("mp3")) w.Header().Set("Content-Type", "application/json") w.Write(buildMiMoTTSResponse(audioB64)) }) defer srv.Close() svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 5, zap.NewNop().Sugar()) // 空句子应该被跳过 textStream := sendSentences("", "你好", "") ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{}) if err != nil { t.Fatalf("SynthesizeStream() error: %v", err) } var chunks []Chunk for c := range ch { chunks = append(chunks, c) } // 只有 "你好" 应该被合成 if atomic.LoadInt32(&callCount) != 1 { t.Errorf("API called %d times, want 1", callCount) } // 1 个音频 + 1 个 IsLast if len(chunks) != 2 { t.Fatalf("got %d chunks, want 2", len(chunks)) } } func TestMiMoService_SynthesizeStream_ContextCancelled(t *testing.T) { srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) { audioB64 := base64.StdEncoding.EncodeToString([]byte("mp3")) w.Header().Set("Content-Type", "application/json") w.Write(buildMiMoTTSResponse(audioB64)) }) defer srv.Close() svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 5, zap.NewNop().Sugar()) textStream := make(chan string, 3) textStream <- "第一句" textStream <- "第二句" textStream <- "第三句" close(textStream) ctx, cancel := context.WithCancel(context.Background()) // 立即取消 cancel() ch, err := svc.SynthesizeStream(ctx, textStream, Options{}) if err != nil { t.Fatalf("SynthesizeStream() error: %v", err) } // 消费 channel,应该很快结束 var count int for range ch { count++ } // 可能收到 0 个或 1 个 chunk,取决于时序 t.Logf("received %d chunks after context cancel", count) } func TestMiMoService_SynthesizeStream_PartialFailure(t *testing.T) { var callCount int32 srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) { n := atomic.AddInt32(&callCount, 1) if n == 2 { // 第二个句子失败 w.WriteHeader(http.StatusInternalServerError) fmt.Fprintf(w, "error") return } audioB64 := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("mp3-%d", n))) w.Header().Set("Content-Type", "application/json") w.Write(buildMiMoTTSResponse(audioB64)) }) defer srv.Close() svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 5, zap.NewNop().Sugar()) textStream := sendSentences("第一句", "第二句", "第三句") ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{}) if err != nil { t.Fatalf("SynthesizeStream() error: %v", err) } var chunks []Chunk for c := range ch { chunks = append(chunks, c) } // 2 个成功音频 + 1 个 IsLast(第二句被跳过) if len(chunks) != 3 { t.Fatalf("got %d chunks, want 3", len(chunks)) } if !chunks[len(chunks)-1].IsLast { t.Error("last chunk should be IsLast") } } func TestMiMoService_SynthesizeStream_CustomVoice(t *testing.T) { srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) var req mimoTTSRequest if err := json.Unmarshal(body, &req); err != nil { t.Errorf("unmarshal request: %v", err) } if req.Audio.Voice != "茉莉" { t.Errorf("voice = %q, want %q", req.Audio.Voice, "茉莉") } audioB64 := base64.StdEncoding.EncodeToString([]byte("mp3")) w.Header().Set("Content-Type", "application/json") w.Write(buildMiMoTTSResponse(audioB64)) }) defer srv.Close() svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 5, zap.NewNop().Sugar()) textStream := sendSentences("你好") ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{Voice: "茉莉"}) if err != nil { t.Fatalf("SynthesizeStream() error: %v", err) } for range ch { } } func TestMiMoService_SynthesizeStream_DefaultVoice(t *testing.T) { srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) var req mimoTTSRequest if err := json.Unmarshal(body, &req); err != nil { t.Errorf("unmarshal request: %v", err) } // 未指定 voice 时应使用默认 "冰糖" if req.Audio.Voice != "冰糖" { t.Errorf("voice = %q, want %q (default)", req.Audio.Voice, "冰糖") } audioB64 := base64.StdEncoding.EncodeToString([]byte("mp3")) w.Header().Set("Content-Type", "application/json") w.Write(buildMiMoTTSResponse(audioB64)) }) defer srv.Close() // 不指定 voice svc := NewMiMoService("test-key", "", "", srv.URL, 5, zap.NewNop().Sugar()) textStream := sendSentences("你好") ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{}) if err != nil { t.Fatalf("SynthesizeStream() error: %v", err) } for range ch { } } func TestMiMoService_SynthesizeStream_EmptyAudioData(t *testing.T) { srv := mockMiMoTTSServer(t, func(w http.ResponseWriter, r *http.Request) { // 返回空音频数据 w.Header().Set("Content-Type", "application/json") w.Write(buildMiMoTTSResponse("")) }) defer srv.Close() svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 5, zap.NewNop().Sugar()) textStream := sendSentences("你好") ch, err := svc.SynthesizeStream(context.Background(), textStream, Options{}) if err != nil { t.Fatalf("SynthesizeStream() error: %v", err) } var chunks []Chunk for c := range ch { chunks = append(chunks, c) } // 空音频数据导致错误,句子被跳过,只有 IsLast if len(chunks) != 1 { t.Fatalf("got %d chunks, want 1", len(chunks)) } if !chunks[0].IsLast { t.Error("chunk should be IsLast") } }