Files
Cyrene_v2/cmd/cyrene/wire_voice_tts_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

128 lines
4.8 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 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)
}
}