package tts // 本文件锁定 `Client` 的五条对外口径(每条都能被"改坏即打红"): // // 1. 🔴 **中文正文必须以 UTF-8 送出**(塔实测:GBK 字节会让服务端回 // `400 {"detail":"There was an error parsing the body"}`)。用例既断言服务端能按 // UTF-8 解出**逐字相等**的正文,也断言请求字节里能直接搜到那段中文 —— 后者是 // 把"改成字符串拼接/转码"这类改动的代价变成红灯的钉子。 // 2. 非 200 ⇒ `ErrUpstream`(且**错误里不带正文**)。 // 3. 超时 ⇒ `ErrUnreachable` 且保留 `context.DeadlineExceeded`(可 errors.Is)。 // 4. 200 但不是音频 ⇒ `ErrNotAudio`(绝不把 JSON/HTML 错误页当音频下发)。 // 5. 未启用 / 空文本 / 超限各自有可分类的哨兵。 import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) // chineseSpeech 是塔实测那台服务能合成的那句话(保留全角标点,逐字比对)。 const chineseSpeech = "你好,我是苏梦。今天心情特别好,我们一起出去玩吧!" // wavBytes 造一段**魔数正确**的假 WAV(嗅探只看头,内容是什么不影响判据)。 func wavBytes(payload string) []byte { b := make([]byte, 0, 44+len(payload)) b = append(b, []byte("RIFF")...) b = append(b, 0, 0, 0, 0) b = append(b, []byte("WAVEfmt ")...) b = append(b, bytes.Repeat([]byte{0}, 16)...) b = append(b, []byte("data")...) b = append(b, 0, 0, 0, 0) return append(b, []byte(payload)...) } // newTestClient 指向一个 httptest 服务;可变选项留给各用例。 func newTestClient(t *testing.T, handler http.HandlerFunc, opts ...Option) *Client { t.Helper() srv := httptest.NewServer(handler) t.Cleanup(srv.Close) base := []Option{WithLogger(discardLogger())} base = append(base, opts...) return New(Config{Enabled: true, BaseURL: srv.URL, Timeout: 2 * time.Second}, base...) } // TestSpeechSendsUTF8ChineseText 是**本切片最重要的一条**:中文正文必须按 UTF-8 // 字节、以 `application/json` 送出,且字段名/取值与上游 openapi 逐字一致。 func TestSpeechSendsUTF8ChineseText(t *testing.T) { var gotPayload speechPayload var gotRaw []byte var gotCT string client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { gotCT = r.Header.Get("Content-Type") gotRaw, _ = io.ReadAll(r.Body) if err := json.Unmarshal(gotRaw, &gotPayload); err != nil { t.Errorf("请求体不是合法 JSON(说明有人改成了拼接/转码): %v", err) } w.Header().Set("Content-Type", "audio/wav") _, _ = w.Write(wavBytes("ok")) }) audio, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech) if err != nil { t.Fatalf("Speech: %v", err) } if gotCT != "application/json" { t.Errorf("Content-Type = %q, want application/json", gotCT) } // ① 服务端按 UTF-8 解出来必须**逐字相等**(GBK 字节在这里必然对不上)。 if gotPayload.Input != chineseSpeech { t.Fatalf("input = %q, want %q", gotPayload.Input, chineseSpeech) } // ② 请求字节里必须能直接搜到那段中文的 UTF-8 编码 —— 一旦有人改成 GBK 转码, // 这里立刻红(UTF-8 的"你好"= E4BD A0 E5A5 BD)。 if !bytes.Contains(gotRaw, []byte(chineseSpeech)) { t.Fatalf("请求体里找不到 UTF-8 正文(字节被转码了?): %q", string(gotRaw)) } if !bytes.Contains(gotRaw, []byte("\xe4\xbd\xa0\xe5\xa5\xbd")) { t.Fatalf("请求体里找不到「你好」的 UTF-8 字节") } if gotPayload.Model != modelName { t.Errorf("model = %q, want %q", gotPayload.Model, modelName) } if gotPayload.Voice != DefaultCharacter { t.Errorf("voice = %q, want %q", gotPayload.Voice, DefaultCharacter) } // response_format 恒为 wav 且**不可配置**(上游产物无转码,见 requestFormat)。 if gotPayload.ResponseFormat != "wav" { t.Errorf("response_format = %q, want 硬编码的 wav", gotPayload.ResponseFormat) } // ③ 回发的格式由**字节嗅探**裁决,不是请求里那个字段。 if audio.Format != "wav" || len(audio.Data) == 0 { t.Fatalf("audio = %+v, want wav with bytes", audio) } } // TestSpeechHTMLEscapingDisabled 钉住"正文逐字不变":`<`/`>`/`&` 不得被改写成 \u003c。 func TestSpeechHTMLEscapingDisabled(t *testing.T) { var gotRaw []byte client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { gotRaw, _ = io.ReadAll(r.Body) _, _ = w.Write(wavBytes("ok")) }) if _, err := client.Speech(context.Background(), DefaultCharacter, "ad"); err != nil { t.Fatalf("Speech: %v", err) } if bytes.Contains(gotRaw, []byte(`\u003c`)) || bytes.Contains(gotRaw, []byte(`\u0026`)) { t.Fatalf("正文被 HTML 转义了: %q", string(gotRaw)) } } // TestSpeechRejectsNon200 钉住上游拒绝:可分类、带状态码、**不带正文**。 func TestSpeechRejectsNon200(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"detail":"There was an error parsing the body"}`)) }) _, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech) if !errors.Is(err, ErrUpstream) { t.Fatalf("err = %v, want ErrUpstream", err) } if !strings.Contains(err.Error(), "400") { t.Errorf("错误里应带状态码: %v", err) } if strings.Contains(err.Error(), "苏梦") { t.Errorf("错误链里出现了正文(红线:日志/错误不带正文): %v", err) } if got := client.Stats(); got.Attempts != 1 || got.Failures != 1 { t.Errorf("stats = %+v, want 1 attempt / 1 failure", got) } } // TestSpeechTimeout 钉住超时:归 ErrUnreachable,且因果链仍能判出 deadline。 func TestSpeechTimeout(t *testing.T) { release := make(chan struct{}) t.Cleanup(func() { close(release) }) handler := func(w http.ResponseWriter, r *http.Request) { select { case <-release: case <-time.After(2 * time.Second): } _, _ = w.Write(wavBytes("late")) } client := newTestClient(t, handler, WithHTTPClient(&http.Client{Timeout: 60 * time.Millisecond})) start := time.Now() _, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech) if !errors.Is(err, ErrUnreachable) { t.Fatalf("err = %v, want ErrUnreachable", err) } if !errors.Is(err, context.DeadlineExceeded) { t.Errorf("err = %v, want wraps context.DeadlineExceeded", err) } if elapsed := time.Since(start); elapsed > time.Second { t.Errorf("超时没有生效(用了 %v)", elapsed) } } // TestSpeechRejectsNonAudio 钉住"200 但不是音频":JSON 错误页绝不当音频。 func TestSpeechRejectsNonAudio(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"success","audio_url":"/outputs/x.wav"}`)) }) _, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech) if !errors.Is(err, ErrNotAudio) { t.Fatalf("err = %v, want ErrNotAudio", err) } if got := client.Stats(); got.Failures != 1 { t.Errorf("stats = %+v, want 1 failure", got) } } // TestSpeechRejectsOversizeResponse 钉住体积上限。 func TestSpeechRejectsOversizeResponse(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(wavBytes(strings.Repeat("x", 128))) }, WithMaxResponseBytes(32)) _, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech) if !errors.Is(err, ErrTooLarge) { t.Fatalf("err = %v, want ErrTooLarge", err) } } // TestSpeechGuards 钉住"不该发网络"的两条前置拒绝,以及"未启用"的可区分性。 func TestSpeechGuards(t *testing.T) { var hits atomic.Int64 client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { hits.Add(1) _, _ = w.Write(wavBytes("ok")) }) if _, err := client.Speech(context.Background(), DefaultCharacter, " "); !errors.Is(err, ErrEmptyText) { t.Errorf("空文本 err = %v, want ErrEmptyText", err) } disabled := New(Config{Enabled: false, BaseURL: client.cfg.BaseURL}, WithLogger(discardLogger())) if _, err := disabled.Speech(context.Background(), DefaultCharacter, chineseSpeech); !errors.Is(err, ErrDisabled) { t.Errorf("未启用 err = %v, want ErrDisabled", err) } // 被拒的两次都不该打网络;未启用也不该被计成"失败"。 if got := hits.Load(); got != 0 { t.Errorf("上游被调用 %d 次,want 0", got) } if got := disabled.Stats(); got.Attempts != 0 || got.Failures != 0 { t.Errorf("未启用却计了数: %+v", got) } } // TestConfigNormalized 钉住默认值的补齐与空白/键的归一化(**没有**格式项了:那个 // 假开关已删除,请求体里的 `response_format` 恒为 wav,见 requestFormat)。 // // 🔴 它同时钉住一条**反向**口径:归一化**不得**给任何角色兜底 —— 代码里写死一个 // 默认发音人,正是主人点名的病害(换人格仍用老声音)。 func TestConfigNormalized(t *testing.T) { got := Config{}.Normalized() if got.Enabled { t.Errorf("零值配置不该被悄悄启用") } if got.BaseURL != DefaultBaseURL || got.Timeout != DefaultTimeout { t.Fatalf("默认值不对: %+v", got) } if got.Default != "" || len(got.Personas) != 0 { t.Fatalf("归一化给发音人兜底了(红线:不许有代码默认角色): %+v", got) } if custom := (Config{BaseURL: " http://127.0.0.1:9880/ "}).Normalized(); custom.BaseURL != DefaultBaseURL { t.Errorf("归一化不对: %+v", custom) } // persona/character 两端的空白被清掉;空 personaId 的键被丢弃。 trimmed := (Config{Personas: map[string]string{" a ": " char_1 ", "": "char_2"}, Default: " char_3 "}).Normalized() if trimmed.Personas["a"] != "char_1" || len(trimmed.Personas) != 1 || trimmed.Default != "char_3" { t.Fatalf("映射/默认值没有被归一化: %+v", trimmed) } }