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) } }