package main // 本文件锁定切片 M47 在**组合根**上的口径(都是"改坏就静默开错/静默拖慢启动"的那种): // // 1. 面板文件没配过 / 写了 `enabled:false` ⇒ **不启用**(返回 nil),连探活都不发; // 2. 启用后 `startVoiceTTSLaunch` **立即返回**,即使探活会挂住整整一个超时 —— // 证明 TTS 的延迟不计入后端启动时长(它是唯一"启动耗时"可测的量); // 3. 拉不起来(脚本不存在)⇒ 只有 WARN,**没有任何 error 回流**到启动路径。 // 4. 探活地址**单向**回退(塔 2026-10-26 裁决):launch 文件显式写了 `probeUrl` // 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者都空才是默认 9880。 // 启动 Info 行必须同时给出地址与来源。 // // 它用 `DefaultVoiceTTSLaunchPath` 的真实落点 // ($DATA_DIR/config/voice-tts-launch.json)跑,因此也钉住"零新 .env 键"。 import ( "bytes" "context" "fmt" "log/slog" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch" ) // captureBuf 是并发安全的日志缓冲(WARN 由后台 goroutine 写)。 type captureBuf struct { mu sync.Mutex buf bytes.Buffer } func (b *captureBuf) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *captureBuf) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } // launchTestApp 造一个只够本题用的 app(数据目录 + 收日志的 logger)。 func launchTestApp(t *testing.T, dataDir string) (*app, *captureBuf) { t.Helper() buf := &captureBuf{} log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})) return &app{cfg: &config.Config{DataDir: dataDir}, log: log}, buf } // writeLaunchPanel 把面板文件写到真实落点($DATA_DIR/config/voice-tts-launch.json)。 func writeLaunchPanel(t *testing.T, dataDir, body string) { t.Helper() path := runtimeconfig.DefaultVoiceTTSLaunchPath(dataDir) 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) } } // TestStartVoiceTTSLaunch_DefaultOff 钉住"默认关":没配过 ⇒ 不构造、不探活、不拉起。 func TestStartVoiceTTSLaunch_DefaultOff(t *testing.T) { a, buf := launchTestApp(t, t.TempDir()) start := time.Now() if l := a.startVoiceTTSLaunch(context.Background()); l != nil { t.Fatal("没配过却启用了自启") } if elapsed := time.Since(start); elapsed > 100*time.Millisecond { t.Fatalf("默认关也花了 %v", elapsed) } if !strings.Contains(buf.String(), "not enabled") { t.Fatalf("没留一条「没启用」的痕迹: %s", buf.String()) } } // TestStartVoiceTTSLaunch_ExplicitFalse 钉住"显式 false 同样一次都不拉"。 func TestStartVoiceTTSLaunch_ExplicitFalse(t *testing.T) { dir := t.TempDir() writeLaunchPanel(t, dir, `{"enabled":false}`) a, _ := launchTestApp(t, dir) if l := a.startVoiceTTSLaunch(context.Background()); l != nil { t.Fatal("enabled:false 却启用了自启") } } // TestStartVoiceTTSLaunch_ReturnsBeforeProbe 是本切片的"启动耗时"证明: // 探活会挂住一个 800ms 的超时(对端只接连接不回话),而挂载动作必须在毫秒级返回。 // // 同时它顺带钉住第 3 条:脚本不存在 ⇒ 后台只记 WARN,`startVoiceTTSLaunch` 自己 // 没有任何 error 可返回(签名里就没有),后端启动路径不受影响。 func TestStartVoiceTTSLaunch_ReturnsBeforeProbe(t *testing.T) { dir := t.TempDir() blackhole, addr := blackholeListener(t) defer blackhole.Close() writeLaunchPanel(t, dir, fmt.Sprintf( `{"enabled":true,"probeUrl":%q,"probeTimeoutMs":800,"scriptPath":%q}`, "http://"+addr+"/", filepath.Join(dir, "不存在的一键启动.bat"))) a, buf := launchTestApp(t, dir) start := time.Now() launcher := a.startVoiceTTSLaunch(context.Background()) elapsed := time.Since(start) if launcher == nil { t.Fatal("启用后应拿到 launcher") } if elapsed > 150*time.Millisecond { t.Fatalf("挂载花了 %v —— 探活被同步等在了启动路径上", elapsed) } // 后台那次探活会等到 800ms 超时,然后去拉一个不存在的脚本。 launcher.Wait() out := buf.String() if !strings.Contains(out, "level=WARN") || !strings.Contains(out, "backend startup unaffected") { t.Fatalf("拉起失败没有只记 WARN: %s", out) } } // TestVoiceTTSLaunchConfig_MapsTuning 钉住"面板字段 → launcher 配置"的映射: // 缺省值不由组合根裁决(空字段原样传下去,由 ttslaunch.Config.Normalized 补)。 func TestVoiceTTSLaunchConfig_MapsTuning(t *testing.T) { enabled := true cfg := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{ Enabled: &enabled, ScriptPath: `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`, ProbeURL: "http://127.0.0.1:9880", ProbeTimeoutMs: 250, CooldownSec: 90, }) if !cfg.Enabled { t.Fatal("enabled 没映射过来") } if cfg.ProbeTimeout != 250*time.Millisecond || cfg.Cooldown != 90*time.Second { t.Fatalf("超时/冷却没映射: %+v", cfg) } if cfg.ScriptPath != ttslaunch.DefaultScriptPath || cfg.ProbeURL != ttslaunch.DefaultProbeURL { t.Fatalf("路径/地址没映射: %+v", cfg) } // 空配置 ⇒ 不启用,且一个缺省值都不在这里填(Normalized 才是唯一裁决处)。 if empty := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{}); empty.Enabled { t.Fatal("空配置不该启用") } else if empty.ScriptPath != "" || empty.ProbeURL != "" || empty.ProbeTimeout != 0 { t.Fatalf("组合根不该替 launcher 填缺省值: %+v", empty) } } // blackholeListener 起一个"只接连接、绝不回话"的回环监听:探活会一直等到自己的 // 超时,正好用来证明挂载动作没有同步等它。 func blackholeListener(t *testing.T) (net.Listener, string) { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } var conns []net.Conn var mu sync.Mutex go func() { for { c, err := ln.Accept() if err != nil { return } mu.Lock() conns = append(conns, c) mu.Unlock() } }() t.Cleanup(func() { mu.Lock() defer mu.Unlock() for _, c := range conns { _ = c.Close() } }) return ln, ln.Addr().String() } // TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS 钉住**单向回退**的第一向: // launch 文件没写 `probeUrl` 时,用 `voice-tts.json` 的 `baseUrl`(主人换端口只该 // 改一处)。 // // 它不只比字符串 —— 真让探活打到那个端口(hits > 0):把回退摘掉,探活就会跑去 // 默认 9880,本用例立刻红。 func TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS(t *testing.T) { dir := t.TempDir() voiceTTS, hits := countingServer(t) a, buf := launchTestApp(t, dir) // 合成面把端口改成了自定义端口;自启面**没写** probeUrl。 writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q,"personas":{"cyrene":"char_1"}}`, voiceTTS.URL)) writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`, filepath.Join(dir, "不存在的一键启动.bat"))) launcher := a.startVoiceTTSLaunch(context.Background()) if launcher == nil { t.Fatal("启用后应拿到 launcher") } if got := launcher.Config().ProbeURL; got != voiceTTS.URL { t.Fatalf("探活地址 = %q, want voice-tts.json 的 baseUrl %q", got, voiceTTS.URL) } launcher.Wait() if n := atomic.LoadInt32(hits); n == 0 { t.Fatal("探活没打到 voice-tts.json 的 baseUrl 端口(回退失效)") } if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts.json") { t.Fatalf("启动 Info 行没标出地址是兜底来的: %s", out) } } // TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault 钉住第二向:两个文件都没给 // 地址 ⇒ 默认 `http://127.0.0.1:9880`,且日志标 `default`。 func TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault(t *testing.T) { dir := t.TempDir() a, buf := launchTestApp(t, dir) // 只有自启文件,没有 voice-tts.json。 writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`, filepath.Join(dir, "不存在的一键启动.bat"))) launcher := a.startVoiceTTSLaunch(context.Background()) if launcher == nil { t.Fatal("启用后应拿到 launcher") } if got := launcher.Config().ProbeURL; got != ttslaunch.DefaultProbeURL { t.Fatalf("探活地址 = %q, want 默认 %q", got, ttslaunch.DefaultProbeURL) } launcher.Wait() if out := buf.String(); !strings.Contains(out, "probe_url_source=default") { t.Fatalf("启动 Info 行没标出地址用的是默认值: %s", out) } } // TestStartVoiceTTSLaunch_ExplicitProbeURLWins 钉住"显式压过兜底":launch 文件写了 // `probeUrl` ⇒ 用它,**永不**去问 voice-tts.json 的 baseUrl(后者一次都不该被探)。 func TestStartVoiceTTSLaunch_ExplicitProbeURLWins(t *testing.T) { dir := t.TempDir() explicit, explicitHits := countingServer(t) inherited, inheritedHits := countingServer(t) a, buf := launchTestApp(t, dir) writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q}`, inherited.URL)) writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":%q,"scriptPath":%q}`, explicit.URL, filepath.Join(dir, "不存在的一键启动.bat"))) launcher := a.startVoiceTTSLaunch(context.Background()) if launcher == nil { t.Fatal("启用后应拿到 launcher") } if got := launcher.Config().ProbeURL; got != explicit.URL { t.Fatalf("探活地址 = %q, want 显式 %q", got, explicit.URL) } launcher.Wait() if atomic.LoadInt32(explicitHits) == 0 { t.Fatal("显式 probeUrl 没被用来探活") } if n := atomic.LoadInt32(inheritedHits); n != 0 { t.Fatalf("显式 probeUrl 仍去问了 voice-tts.json 的地址 %d 次", n) } if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts-launch.json") { t.Fatalf("启动 Info 行没标出地址是显式配置的: %s", out) } } // countingServer 起一个"数请求次数"的 httptest 服务:用来证明探活**真的打到了** // 某个地址,而不只是配置里写着那个字符串。 func countingServer(t *testing.T) (*httptest.Server, *int32) { t.Helper() var hits int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { atomic.AddInt32(&hits, 1) w.WriteHeader(http.StatusOK) })) t.Cleanup(srv.Close) return srv, &hits }