P2-1(两处注释在描述已被推翻的旧语义,会把 r2 的修复写回去):
- voice.go 的 voiceReplySink 文档:原写「personaID = env.PersonaID,会话无绑定时为空」
⇒ 实际传的是 turnPersona.Effective,**未绑定 ≠ 空**(有全局默认人格时非空)。
改为写明「生效人格(会话绑定 → 全局默认人格;两者都没有才是空)」,并显式指出
它不是 env.PersonaID、把空当"没有角色"会让主会话落到 default(正是 r2 修掉的)。
- voice.go 的 synthesizeVoiceReply:原写「空 personaID = 会话无绑定 ⇒ 走 default」
⇒ 反了。改为「空 = 连全局默认人格都没有 ⇒ 才走 voice-tts.json 的 default」。
- 同族 format 残留措辞:client_test.go 的「非法格式的回落」(该行为已不存在)、
cmd/cyrene/wire_voice_tts_test.go 的「角色/格式/超时按文件来」都改写。
- nit:voice.go 的 `env, turnPersona := ...` 遮蔽同包类型名,改成 `tp`。
P2-2(我上轮那条「摘掉 PrimaryActive 闸 ⇒ 用例红」不成立,复审实测仍绿):
根因是 audioStubVoice 不实现 PrimaryActive,拦住 persona 读取的只有类型断言。
按塔选的第二种收口(把测试做强):
- audioStubVoice 补 `PrimaryActive() bool { return true }`(它是"生效中的 persona
voice"替身),并在注释里说明它存在的理由;
- 关闭态用例改用新替身 inactivePersonaVoice(实现 personaVoice 但报 false),
于是 PrimaryActive 成为关闭态唯一的闸;
- 重跑变异(摘掉 `&& pv.PrimaryActive()`)**实测打红**:
「未生效的 personaVoice 却查了 1 次会话人格(关闭态行为变了)」。
未动:功能面、错误码、500 状态码、白名单、session 可读、隐私/format/Stats/redactURL。
go build ./... + go vet ./... + go test -count=1 -race 覆盖 tts/runtimeconfig/gateway/
cmd/cyrene 全绿。
240 lines
10 KiB
Go
240 lines
10 KiB
Go
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, "a<b & c>d"); 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)
|
||
}
|
||
}
|