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 全绿。
128 lines
4.8 KiB
Go
128 lines
4.8 KiB
Go
package main
|
||
|
||
// 本文件锁定切片 M35 在**组合根**上的三条口径(都是"改坏就静默不工作/静默开错"的那种):
|
||
//
|
||
// 1. 面板文件缺失 ⇒ **这一级不启用**,返回的仍是既有链本身(行为逐字不变);
|
||
// 2. 文件里 `enabled:true` ⇒ 返回 emotionTTS 门面,`personas`/`default`/`timeoutSec`
|
||
// 按文件来(没有"角色"或"格式"这种全局键:发音人只能来自 persona 映射);
|
||
// 3. 文件坏掉 ⇒ **不启用**(宁可没有这一级,也不拿读不懂的配置去驱动外部服务)。
|
||
//
|
||
// 它用 `DefaultVoiceTTSPath` 的真实落点($DATA_DIR/config/voice-tts.json)跑,
|
||
// 因此也钉住"零新 .env 键":没有任何一处读环境变量。
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
|
||
)
|
||
|
||
// voiceTTSApp 造一个只够本题用的 app:数据目录 + 一条既有链。
|
||
func voiceTTSApp(t *testing.T) *app {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
return &app{
|
||
cfg: &config.Config{DataDir: dir},
|
||
log: tidyTestLog(),
|
||
voice: mvoice.New(config.VoiceConfig{}),
|
||
}
|
||
}
|
||
|
||
// TestVoiceForGateway_DisabledByDefault 钉住"没配过 ⇒ 原样用既有链"。
|
||
func TestVoiceForGateway_DisabledByDefault(t *testing.T) {
|
||
a := voiceTTSApp(t)
|
||
got := a.voiceForGateway()
|
||
if got != any(a.voice) {
|
||
t.Fatalf("没配过时不该包装既有链: %T", got)
|
||
}
|
||
}
|
||
|
||
// TestVoiceForGateway_WrapsWhenEnabled 钉住"文件启用 ⇒ 门面生效 + 配置逐项读对"。
|
||
//
|
||
// 🔴 它同时钉住主人 2026-10-26 那条硬要求在**组合根**上的形态:配置里读出来的是
|
||
// `personas` 映射与 `default`,**没有**任何"全局一个 character"。
|
||
func TestVoiceForGateway_WrapsWhenEnabled(t *testing.T) {
|
||
a := voiceTTSApp(t)
|
||
writeVoiceTTSPanel(t, a, `{"enabled":true,"baseUrl":"http://127.0.0.1:9880",
|
||
"personas":{"cyrene":"char_84fb88a3","furina":"char_5314489e"},
|
||
"default":"char_84fb88a3","format":"wav","timeoutSec":45}`)
|
||
|
||
cfg, enabled := a.voiceTTSConfig()
|
||
if !enabled {
|
||
t.Fatal("面板写了 enabled:true,但组合根报未启用")
|
||
}
|
||
if len(cfg.Personas) != 2 || cfg.Personas["furina"] != "char_5314489e" {
|
||
t.Fatalf("personas 没有逐项读出: %+v", cfg.Personas)
|
||
}
|
||
if cfg.Default != "char_84fb88a3" {
|
||
t.Fatalf("default = %q, want char_84fb88a3", cfg.Default)
|
||
}
|
||
|
||
got := a.voiceForGateway()
|
||
if _, ok := got.(*voicetts.Service); !ok {
|
||
t.Fatalf("启用后应返回 *tts.Service, got %T", got)
|
||
}
|
||
}
|
||
|
||
// TestVoiceForGateway_LegacyCharacterIgnored 钉住已废弃的全局 character **不生效**:
|
||
// 只写旧键时,映射与 default 都是空的 ⇒ 任何请求都选不到发音人(不会悄悄回退到
|
||
// 那个全局角色,因为那正是主人担心的病害)。
|
||
func TestVoiceForGateway_LegacyCharacterIgnored(t *testing.T) {
|
||
a := voiceTTSApp(t)
|
||
writeVoiceTTSPanel(t, a, `{"enabled":true,"character":"char_84fb88a3"}`)
|
||
|
||
cfg, enabled := a.voiceTTSConfig()
|
||
if !enabled {
|
||
t.Fatal("enabled:true 应让这一级启用")
|
||
}
|
||
if len(cfg.Personas) != 0 || cfg.Default != "" {
|
||
t.Fatalf("废弃的全局 character 被当成了发音人: personas=%v default=%q", cfg.Personas, cfg.Default)
|
||
}
|
||
}
|
||
|
||
// TestVoiceForGateway_BadFileStaysDisabled 钉住"坏文件 ⇒ 不启用"(绝不半开)。
|
||
func TestVoiceForGateway_BadFileStaysDisabled(t *testing.T) {
|
||
a := voiceTTSApp(t)
|
||
writeVoiceTTSPanel(t, a, `{"enabled":`)
|
||
|
||
got := a.voiceForGateway()
|
||
if _, ok := got.(*voicetts.Service); ok {
|
||
t.Fatal("坏文件却启用了 emotionTTS(会拿读不懂的配置去调外部服务)")
|
||
}
|
||
if got != any(a.voice) {
|
||
t.Fatalf("坏文件时应原样用既有链, got %T", got)
|
||
}
|
||
}
|
||
|
||
// TestVoiceTTSConfig_NoEnvKeys 钉住"零新 .env 键":把相关环境变量设成垃圾值,
|
||
// 配置解析的结果只由面板文件决定(证明它根本没读环境)。
|
||
func TestVoiceTTSConfig_NoEnvKeys(t *testing.T) {
|
||
a := voiceTTSApp(t)
|
||
t.Setenv("VOICE_TTS_PROVIDER", "emotiontts")
|
||
t.Setenv("EMOTIONTTS_BASE_URL", "http://evil.invalid")
|
||
writeVoiceTTSPanel(t, a, `{"enabled":true}`)
|
||
|
||
cfg, enabled := a.voiceTTSConfig()
|
||
if !enabled {
|
||
t.Fatal("面板启用却没启用")
|
||
}
|
||
if cfg.BaseURL != "" {
|
||
t.Errorf("baseURL = %q,want 空(默认值由 Normalized 补,不读环境)", cfg.BaseURL)
|
||
}
|
||
}
|
||
|
||
// writeVoiceTTSPanel 把内容写进**真实落点**($DATA_DIR/config/voice-tts.json)。
|
||
func writeVoiceTTSPanel(t *testing.T, a *app, body string) {
|
||
t.Helper()
|
||
path := filepath.Join(a.cfg.DataDir, "config", "voice-tts.json")
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
t.Fatalf("mkdir: %v", err)
|
||
}
|
||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||
t.Fatalf("write panel: %v", err)
|
||
}
|
||
}
|