Files
Cyrene_v2/internal/runtime/voice/tts/client_test.go
T
AskaEth 1c2944fcf4 docs(voice): 收回 r2 修掉的旧语义注释 + 让关闭态变异真能打红(r3 P2-1/P2-2)
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 全绿。
2026-10-26 13:32:04 +08:00

240 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}