上一版把 r1 的三条 finding 静默漏了(我在交付前没读 inbox),本提交逐条补齐:
1. 🔴 隐私:upstreamStatus 不再读也不记响应体
FastAPI 的 422 会把请求体里的 input 整段回显,原来「错误体前 512 字节进 WARN」
等于把她的原话写进日志。现在只记 status / content_type / response_bytes
(字节数优先取 Content-Length,缺失时有界排空取上界,读到即丢)。
新用例 TestSpeechRejectLogOmitsRequestText 用**回显正文**的假上游抓它;
变异(把 body 塞回 WARN)实测打红。
2. format 键彻底删除(假开关)
上游只拿 response_format 改自报 media_type,产物恒 .wav 无转码,我方只按字节
嗅探裁决 ⇒ wav/mp3/ogg 交付字节与响应头完全相同。删 tts.Config.Format 与
VoiceTTSTuning.Format,encodeSpeech 硬编码 requestFormat="wav"(不可配置)。
用例钉住请求体恒为 wav;变异(改成 mp3)打红。旧文件里残留的 format 键被忽略。
3. Stats 接进 degrade 的 WARN(attempts/failures)
否则统计在生产日志里没有消费者。TestServiceDegradeLogsCounters 钉住;
变异(去掉两个字段)打红。
4. redactURL 兜底含 "@" 整体打码
解析失败/无 host 时原来原样返回,user:pass@ 会漏凭据。TestRedactURLCredentials
覆盖合法 URL 去 userinfo、非法含 @ 打码;变异(退回原样返回)打红。
另:关闭态零行为变化的**当前 tip 重跑**——
- handleSpeech 的 persona 解析改为只在语音服务实现 personaVoice 且 PrimaryActive
时才查库;未接 emotionTTS(既有链/stub)时零额外 I/O、ctx 不改、音频字节相同。
用例 TestSpeechEndpointDisabledVoiceDoesNotReadPersona(含对照组证明闸不是恒关);
变异(去掉闸)打红。
- voice.go 在 dev 基线里本就有 env.PersonaID 解析(无人格解析新增 I/O),本次未改。
Config.Normalized 去掉未使用的 logger 参数。go build/vet/test(含 -race)全绿;
真机 live 复测:cyrene 264104 B、furina 221194 B。
115 lines
4.3 KiB
Go
115 lines
4.3 KiB
Go
package tts
|
|
|
|
// 本文件锁定**选角**的四条分支(主人 2026-10-26 的验收清单逐条对应)。
|
|
//
|
|
// 最要紧的是 `TestResolveUnmappedPersonaRefuses`:映射缺失时**绝不**拿 default
|
|
// 或别的角色去顶 —— 那正是主人担心的病害(「换了别的角色,还是用老角色的 TTS
|
|
// 角色」)。把这条改成"回落 default"必然打红。
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// selectionCfg 是两份 persona 各映射到**不同** character 的配置。
|
|
func selectionCfg() Config {
|
|
return Config{
|
|
Enabled: true,
|
|
Personas: map[string]string{
|
|
"cyrene": "char_84fb88a3",
|
|
"furina": "char_5314489e",
|
|
"nameless": "",
|
|
},
|
|
Default: "char_fallback",
|
|
}
|
|
}
|
|
|
|
// TestResolveMapsPersonaToCharacter 分支①:有映射 ⇒ 用映射到的角色(两个 persona
|
|
// 各自映射到不同角色,绝不互相串)。
|
|
func TestResolveMapsPersonaToCharacter(t *testing.T) {
|
|
cfg := selectionCfg().Normalized()
|
|
|
|
got, err := cfg.Resolve("cyrene")
|
|
if err != nil || got.Character != "char_84fb88a3" || got.Source != SourceMapped {
|
|
t.Fatalf("cyrene ⇒ %+v err=%v", got, err)
|
|
}
|
|
other, err := cfg.Resolve("furina")
|
|
if err != nil || other.Character != "char_5314489e" || other.Source != SourceMapped {
|
|
t.Fatalf("furina ⇒ %+v err=%v", other, err)
|
|
}
|
|
if got.Character == other.Character {
|
|
t.Fatal("两个 persona 拿到了同一个 character(映射被串了)")
|
|
}
|
|
}
|
|
|
|
// TestResolveUnmappedPersonaRefuses 分支④(红线):配了 personas 但**没有这个
|
|
// persona 的键** ⇒ 拒绝,**不看 default**、也不借用任何别的角色。
|
|
func TestResolveUnmappedPersonaRefuses(t *testing.T) {
|
|
cfg := selectionCfg().Normalized()
|
|
|
|
got, err := cfg.Resolve("stranger")
|
|
if !errors.Is(err, ErrPersonaUnmapped) {
|
|
t.Fatalf("未映射 persona err = %v, want ErrPersonaUnmapped", err)
|
|
}
|
|
if got.Character != "" {
|
|
t.Fatalf("未映射 persona 却选出了角色 %q —— 这条就是病害本身", got.Character)
|
|
}
|
|
// 键在但值为空:同样拒绝(不是"回落 default")。
|
|
if got, err := cfg.Resolve("nameless"); !errors.Is(err, ErrPersonaUnmapped) || got.Character != "" {
|
|
t.Fatalf("映射值为空 ⇒ %+v err=%v, want 拒绝", got, err)
|
|
}
|
|
}
|
|
|
|
// TestResolveUnresolvedPersonaUsesDefault 分支②:personaId 解析不到(空串)⇒ 用
|
|
// default(配了才用)。
|
|
func TestResolveUnresolvedPersonaUsesDefault(t *testing.T) {
|
|
cfg := selectionCfg().Normalized()
|
|
got, err := cfg.Resolve("")
|
|
if err != nil || got.Character != "char_fallback" || got.Source != SourceDefault {
|
|
t.Fatalf("空 persona ⇒ %+v err=%v, want default", got, err)
|
|
}
|
|
}
|
|
|
|
// TestResolveUnresolvedPersonaWithoutDefaultRefuses 分支②的另一半:没配 default ⇒
|
|
// 不合成(可分类错误),绝不自己猜一个角色。
|
|
func TestResolveUnresolvedPersonaWithoutDefaultRefuses(t *testing.T) {
|
|
cfg := Config{Enabled: true, Personas: map[string]string{"cyrene": "char_84fb88a3"}}.Normalized()
|
|
if got, err := cfg.Resolve(""); !errors.Is(err, ErrNoCharacter) || got.Character != "" {
|
|
t.Fatalf("空 persona + 无 default ⇒ %+v err=%v, want ErrNoCharacter", got, err)
|
|
}
|
|
}
|
|
|
|
// TestResolveIsNotCachedAcrossPersonas 是主人担心那件事的**回归锁**:选角是纯函数,
|
|
// 同一份配置先后解析两个 persona,第二次绝不沿用第一次的结果。
|
|
func TestResolveIsNotCachedAcrossPersonas(t *testing.T) {
|
|
cfg := selectionCfg().Normalized()
|
|
first, err := cfg.Resolve("cyrene")
|
|
if err != nil {
|
|
t.Fatalf("first: %v", err)
|
|
}
|
|
second, err := cfg.Resolve("furina")
|
|
if err != nil {
|
|
t.Fatalf("second: %v", err)
|
|
}
|
|
if second.Character != "char_5314489e" {
|
|
t.Fatalf("第二次沿用了第一次的 voice(%q)—— 正是主人担心的病害", second.Character)
|
|
}
|
|
if first.Character != "char_84fb88a3" {
|
|
t.Fatalf("第一次的结果被第二次改写了: %q", first.Character)
|
|
}
|
|
}
|
|
|
|
// TestPersonaContextRoundTrip 钉住 ctx 传递(语音面自己不查库,只读这一处)。
|
|
func TestPersonaContextRoundTrip(t *testing.T) {
|
|
if got := PersonaIDFromContext(WithPersonaID(t.Context(), " cyrene ")); got != "cyrene" {
|
|
t.Fatalf("ctx persona = %q, want cyrene", got)
|
|
}
|
|
if got := PersonaIDFromContext(t.Context()); got != "" {
|
|
t.Fatalf("未设置时应为空, got %q", got)
|
|
}
|
|
// nil ctx 不得 panic(防御性)。
|
|
if got := PersonaIDFromContext(nil); got != "" {
|
|
t.Fatalf("nil ctx = %q, want 空", got)
|
|
}
|
|
}
|